Add Binance Testnet exchange gateway

This commit is contained in:
Azimkin
2026-09-19 17:14:46 +02:00
parent 7272147270
commit 93cafe8160
18 changed files with 2505 additions and 230 deletions

View File

@@ -1,27 +1,32 @@
# LLM Trade Bot # 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` для каждого анализа; - новая stateless-сессия `micro-agents` для каждого анализа;
- долговременные `note` и односторонние `ask_admin` через SQLite/Telegram; - долговременные `note` и односторонние `ask_admin` через SQLite/Telegram;
- общий exchange gateway с нормализованными ордерами, сделками и приватными WebSocket-событиями;
- сканер ликвидных Spot и Linear инструментов, свечи 1h/4h/1d, стакан и индикаторы; - сканер ликвидных Spot и Linear инструментов, свечи 1h/4h/1d, стакан и индикаторы;
- Brave Search и безопасное чтение выбранных публичных HTTPS-страниц; - Brave Search и безопасное чтение выбранных публичных HTTPS-страниц;
- максимум 2% equity риска на идею, 40% gross exposure, пять идей и 3x Linear leverage; - максимум 2% equity риска на идею, 40% gross exposure, пять идей и 3x Linear leverage;
- обязательный exchange-side stop-loss, TP или trailing для Linear, TP/SL для Spot; - обязательный 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. 1. Выберите `exchange.provider` в `config.toml`.
2. Настройте Unified account в `ISOLATED_MARGIN` и one-way position mode. Для Spot margin borrowing не используется. 2. Для Bybit создайте ключ в основном аккаунте после переключения в **Demo Trading**, а не на testnet. Настройте Unified account в `ISOLATED_MARGIN` и one-way position mode.
3. Создайте Telegram-бота и получите `api_id`/`api_hash` приложения Telegram. 3. Для Binance создайте два отдельных набора ключей: Spot Testnet на [testnet.binance.vision](https://testnet.binance.vision/) и USDⓈ-M Futures Testnet/Demo. У ключей должны быть права чтения и торговли. Futures-аккаунт должен использовать one-way mode; бот проверит это при запуске и выставит isolated margin/плечо перед входом.
4. Получите DeepSeek API key и Brave Search API key. 4. Создайте Telegram-бота и получите `api_id`/`api_hash` приложения Telegram.
5. Скопируйте `config.example.toml` в `config.toml`, а `prompts.example.md` в `prompts.md`. 5. Получите DeepSeek API key и Brave Search API key.
6. Замените секреты в `config.toml`. Локальные конфиги исключены из Git. 6. Скопируйте `config.example.toml` в `config.toml`, а `prompts.example.md` в `prompts.md`.
7. Замените секреты только выбранного провайдера. Локальные конфиги исключены из Git.
```powershell ```powershell
Copy-Item config.example.toml config.toml Copy-Item config.example.toml config.toml
@@ -50,7 +55,7 @@ uv run llm-trade-bot --config config.toml
- `/run` для внепланового анализа; - `/run` для внепланового анализа;
- `/help`. - `/help`.
`/pause` запрещает новые агентские сессии и входы, но уже размещённые защитные ордера остаются на Bybit. `/pause` запрещает новые агентские сессии и входы, но уже размещённые защитные ордера остаются на выбранной бирже.
## Тесты ## Тесты
@@ -58,4 +63,4 @@ uv run llm-trade-bot --config config.toml
uv run pytest uv run pytest
``` ```
Тесты не используют реальные ключи и не создают ордера. Перед первым полноценным запуском рекомендуется пополнить Demo Trading только USDT и проверить `/status`, `/positions` и `/orders`. Тесты не используют реальные ключи и не создают ордера. Перед первым полноценным запуском проверьте `/status`, `/positions` и `/orders` на выбранном Testnet/Demo-кошельке. Для Binance отдельно проверьте Spot Limit OTOCO и Futures Market-вход с защитой.

View File

@@ -5,11 +5,27 @@ model = "deepseek-flash"
thinking = true thinking = true
max_tool_rounds = 256 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] [bybit]
api_key = "replace-me" api_key = "replace-me"
api_secret = "replace-me" api_secret = "replace-me"
settle_coin = "USDT" 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] [telegram]
api_id = 123456 api_id = 123456
api_hash = "replace-me" api_hash = "replace-me"

View File

@@ -17,6 +17,7 @@ dependencies = [
"pybit>=5.12,<6", "pybit>=5.12,<6",
"tgcrypto>=1.2.5,<2", "tgcrypto>=1.2.5,<2",
"tzdata>=2026.1", "tzdata>=2026.1",
"websockets>=15,<17",
] ]
[project.scripts] [project.scripts]

View File

@@ -24,9 +24,9 @@ from micro_agents import (
ToolManager, ToolManager,
) )
from llm_trade_bot.bybit_gateway import BybitError, BybitGateway
from llm_trade_bot.config import AppConfig from llm_trade_bot.config import AppConfig
from llm_trade_bot.database import Database, utc_now 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.models import Category, OpenTradeRequest, OrderType, Side
from llm_trade_bot.prompts import PromptStore from llm_trade_bot.prompts import PromptStore
from llm_trade_bot.risk import RiskEngine from llm_trade_bot.risk import RiskEngine
@@ -34,10 +34,11 @@ from llm_trade_bot.web_tools import SafeWebTools
class AgentTools: class AgentTools:
def __init__(self, config: AppConfig, database: Database, bybit: BybitGateway): def __init__(self, config: AppConfig, database: Database, exchange: ExchangeGateway):
self.config = config self.config = config
self.database = database 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.risk = RiskEngine(config.risk)
self.web = SafeWebTools(config.brave.api_key) self.web = SafeWebTools(config.brave.api_key)
self._dedup: dict[str, dict[str, Any]] = {} self._dedup: dict[str, dict[str, Any]] = {}
@@ -70,7 +71,7 @@ class AgentTools:
async def get_account_state(self) -> dict[str, Any]: async def get_account_state(self) -> dict[str, Any]:
"""Return current wallet, spot assets, linear positions, and open orders.""" """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( async def get_market_data(
self, self,
@@ -80,19 +81,19 @@ class AgentTools:
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Return ticker, order book, candles, and indicators for a current scanner candidate.""" """Return ticker, order book, candles, and indicators for a current scanner candidate."""
self._require_candidate(category, symbol, context) 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]]: async def get_open_orders(self, category: str | None = None) -> list[dict[str, Any]]:
"""Return active normal and protective orders.""" """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]]: 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 recent normalized order history for spot or linear."""
return await self.bybit.order_history(category, limit) 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]]: 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 recent executions for spot or linear."""
return await self.bybit.executions(category, limit) return await self.exchange.executions(category, limit)
async def open_trade( async def open_trade(
self, self,
@@ -125,11 +126,11 @@ class AgentTools:
category_value = Category(category) category_value = Category(category)
side_value = Side(side) side_value = Side(side)
order_type_value = OrderType(order_type) 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): if _has_existing_idea(state, category, symbol):
raise ValueError(f"An active position or opening order already exists for {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", {}) ticker = market.get("ticker", {})
reference_price = Decimal(str(ticker.get("lastPrice") or "0")) reference_price = Decimal(str(ticker.get("lastPrice") or "0"))
request = OpenTradeRequest( request = OpenTradeRequest(
@@ -146,48 +147,78 @@ class AgentTools:
leverage=Decimal(str(leverage)), leverage=Decimal(str(leverage)),
rationale=rationale[:1000], rationale=rationale[:1000],
) )
account = await self.bybit.account_snapshot() try:
rules = await self.bybit.instrument_rules(category, symbol) 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) 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] order_link_id = f"ltb-{run_id}-{uuid4().hex[:12]}"[:36]
parameters: dict[str, Any] = { if hasattr(self.exchange, "open_trade"):
"category": category, try:
"symbol": symbol.upper(), result = await self.exchange.open_trade(request, validated, order_link_id)
"side": side, except Exception as error:
"orderType": order_type, if "emergency-closed" in str(error):
"qty": _plain(validated.quantity), await self.database.queue_message(
"orderLinkId": order_link_id, f"🚨 {self.exchange.provider}: обязательный stop-loss не установлен; "
} f"позиция {symbol.upper()} аварийно закрыта. {error}"
if order_type_value is OrderType.LIMIT: )
parameters.update(price=_plain(validated.entry_price), timeInForce="GTC") raise
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: else:
parameters.update( # Transitional compatibility for simple legacy gateways.
positionIdx=0, if category_value is Category.LINEAR:
stopLoss=_plain(validated.stop_loss), try:
tpslMode="Partial", await self.exchange.set_leverage(symbol.upper(), str(validated.leverage)) # type: ignore[attr-defined]
slOrderType="Market", 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 = ( expires_at = (
utc_now() + timedelta(hours=self.config.risk.limit_order_ttl_hours) utc_now() + timedelta(hours=self.config.risk.limit_order_ttl_hours)
if order_type_value is OrderType.LIMIT if order_type_value is OrderType.LIMIT
@@ -195,18 +226,28 @@ class AgentTools:
) )
await self.database.upsert_managed_order( await self.database.upsert_managed_order(
order_link_id, 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, category=category,
symbol=symbol.upper(), symbol=symbol.upper(),
side=side, side=side,
status="Created", status=str(result.get("status") or "Created"),
expires_at=expires_at, expires_at=expires_at,
trailing_distance=_plain(validated.trailing_distance), 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 = { output = {
"ok": True, "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, "order_link_id": order_link_id,
"quantity": _plain(validated.quantity), "quantity": _plain(validated.quantity),
"notional": _plain(validated.notional), "notional": _plain(validated.notional),
@@ -231,71 +272,27 @@ class AgentTools:
"""Reduce or close an existing spot holding or linear position.""" """Reduce or close an existing spot holding or linear position."""
if not 0 < percent <= 100: if not 0 < percent <= 100:
raise ValueError("percent must be in (0, 100]") raise ValueError("percent must be in (0, 100]")
rules = await self.bybit.instrument_rules(category, symbol) client_id = f"ltb-close-{uuid4().hex[:16]}"[:32]
quantity: Decimal result = await self.exchange.close_trade(
side: str category,
if category == "linear": symbol.upper(),
positions = await self.bybit.positions() percent,
position = next((item for item in positions if item.get("symbol") == symbol.upper() and Decimal(str(item.get("size") or 0)) > 0), None) order_type,
if position is None: str(limit_price) if limit_price is not None else None,
raise ValueError("No linear position found") client_id,
quantity = Decimal(str(position["size"])) * Decimal(str(percent)) / Decimal(100) )
side = "Sell" if position.get("side") == "Buy" else "Buy" output = {"ok": True, **result}
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)}
run_id = int(str(context.get("run_id", 0))) if context else None 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.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 return output
async def cancel_order(self, category: str, symbol: str, order_id: str) -> dict[str, Any]: async def cancel_order(self, category: str, symbol: str, order_id: str) -> dict[str, Any]:
"""Cancel an active order by Bybit order ID.""" """Cancel an active order by its exchange order ID."""
response = await self.bybit.cancel_order(category=category, symbol=symbol.upper(), orderId=order_id) response = await self.exchange.cancel_order(category, symbol.upper(), order_id=order_id)
return {"ok": True, "result": response.get("result", {})} return {"ok": True, **response}
async def update_protection( async def update_protection(
self, self,
@@ -307,20 +304,12 @@ class AgentTools:
"""Update exchange-side protection for an existing linear position.""" """Update exchange-side protection for an existing linear position."""
if take_profit is None and trailing_distance is None: if take_profit is None and trailing_distance is None:
raise ValueError("take_profit or trailing_distance is required") raise ValueError("take_profit or trailing_distance is required")
parameters: dict[str, Any] = { return await self.exchange.update_protection(
"category": "linear", symbol.upper(),
"symbol": symbol.upper(), str(stop_loss),
"positionIdx": 0, str(take_profit) if take_profit is not None else None,
"tpslMode": "Full", str(trailing_distance) if trailing_distance is not None else None,
"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", {})}
@staticmethod @staticmethod
def _require_candidate(category: str, symbol: str, context: SessionContext | None) -> None: def _require_candidate(category: str, symbol: str, context: SessionContext | None) -> None:
@@ -358,17 +347,18 @@ class AuditSubscriber(EventSubscriber):
class AgentRunner: class AgentRunner:
def __init__(self, config: AppConfig, database: Database, bybit: BybitGateway): def __init__(self, config: AppConfig, database: Database, exchange: ExchangeGateway):
self.config = config self.config = config
self.database = database self.database = database
self.bybit = bybit self.exchange = exchange
self.bybit = exchange
self.prompts = PromptStore(config.prompts.file_path) self.prompts = PromptStore(config.prompts.file_path)
llm = OpenAIBasedLLM( llm = OpenAIBasedLLM(
api_url=config.deepseek.base_url, api_url=config.deepseek.base_url,
api_key=config.deepseek.api_key, api_key=config.deepseek.api_key,
model=config.deepseek.model, model=config.deepseek.model,
) )
self.agent_tools = AgentTools(config, database, bybit) self.agent_tools = AgentTools(config, database, exchange)
self.aggregator = Aggregator( self.aggregator = Aggregator(
llm, llm,
ToolManager(self.agent_tools.tools()), ToolManager(self.agent_tools.tools()),
@@ -391,6 +381,12 @@ class AgentRunner:
runtime_context = json.dumps( runtime_context = json.dumps(
{ {
"trigger": trigger, "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, "account": account,
"scanner_candidates": candidates, "scanner_candidates": candidates,
"durable_notes": notes, "durable_notes": notes,
@@ -428,7 +424,7 @@ class AgentRunner:
raise raise
async def _initial_context(self) -> tuple[list[dict[str, str]], dict[str, Any], list[str]]: 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 = [ candidates = [
{ {
"category": item.category, "category": item.category,
@@ -439,7 +435,7 @@ class AgentRunner:
} }
for item in candidates_raw for item in candidates_raw
] ]
account = await self.bybit.account_state() account = await self.exchange.account_state()
notes = await self.database.recent_notes( notes = await self.database.recent_notes(
self.config.storage.max_notes, self.config.storage.max_notes_chars 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: def _has_existing_idea(state: dict[str, Any], category: str, symbol: str) -> bool:
symbol = symbol.upper() symbol = symbol.upper()
if category == "linear" and any( 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", []) for item in state.get("positions", [])
): ):
return True 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", []) for item in wallets[0].get("coin", [])
): ):
return True 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( return any(
item.get("symbol") == symbol item.get("symbol") == symbol
and item.get("category", category) == category 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", []) for item in state.get("open_orders", [])
) )

View File

@@ -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))

File diff suppressed because it is too large Load Diff

View File

@@ -2,14 +2,24 @@ from __future__ import annotations
import asyncio import asyncio
from collections.abc import Callable from collections.abc import Callable
from contextlib import suppress
from decimal import Decimal, InvalidOperation from decimal import Decimal, InvalidOperation
from typing import Any 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.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.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): class BybitError(RuntimeError):
@@ -19,6 +29,9 @@ class BybitError(RuntimeError):
class BybitGateway: class BybitGateway:
"""Async facade over pybit's synchronous Unified Trading HTTP client.""" """Async facade over pybit's synchronous Unified Trading HTTP client."""
provider = "bybit"
environment = "demo"
def __init__(self, config: BybitConfig, scanner: ScannerConfig): def __init__(self, config: BybitConfig, scanner: ScannerConfig):
self.config = config self.config = config
self.scanner = scanner self.scanner = scanner
@@ -29,6 +42,9 @@ class BybitGateway:
api_secret=config.api_secret, api_secret=config.api_secret,
) )
async def close(self) -> None:
return None
async def startup_check(self) -> dict[str, Any]: async def startup_check(self) -> dict[str, Any]:
account = await self._call(self._http.get_account_info) account = await self._call(self._http.get_account_info)
margin_mode = str(_result(account).get("marginMode", "")) margin_mode = str(_result(account).get("marginMode", ""))
@@ -62,10 +78,10 @@ class BybitGateway:
for order in _result(response).get("list", []): for order in _result(response).get("list", []):
order = dict(order) order = dict(order)
order.setdefault("category", item_category) order.setdefault("category", item_category)
result.append(order) result.append(_normalize_bybit_order(order, item_category))
return result 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()) wallet, positions, orders = await asyncio.gather(self.wallet(), self.positions(), self.open_orders())
accounts = _result(wallet).get("list", []) accounts = _result(wallet).get("list", [])
if not accounts: if not accounts:
@@ -101,6 +117,8 @@ class BybitGateway:
async def account_state(self) -> dict[str, Any]: async def account_state(self) -> dict[str, Any]:
wallet, positions, orders = await asyncio.gather(self.wallet(), self.positions(), self.open_orders()) wallet, positions, orders = await asyncio.gather(self.wallet(), self.positions(), self.open_orders())
return { return {
"provider": self.provider,
"environment": self.environment,
"wallet": _result(wallet).get("list", []), "wallet": _result(wallet).get("list", []),
"positions": positions, "positions": positions,
"open_orders": orders, "open_orders": orders,
@@ -154,6 +172,8 @@ class BybitGateway:
"recent_candles": raw_candles[-20:], "recent_candles": raw_candles[-20:],
} }
return { return {
"provider": self.provider,
"environment": self.environment,
"category": category, "category": category,
"symbol": symbol, "symbol": symbol,
"ticker": (_result(ticker_response).get("list") or [{}])[0], "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]]: 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)) 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]]: 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)) 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]: async def place_order(self, **parameters: Any) -> dict[str, Any]:
return await self._call(self._http.place_order, **parameters) 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) return await self._call(self._http.cancel_order, **parameters)
async def set_leverage(self, symbol: str, leverage: str) -> dict[str, Any]: 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]: async def set_trading_stop(self, **parameters: Any) -> dict[str, Any]:
return await self._call(self._http.set_trading_stop, **parameters) 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]: async def _call(self, method: Callable[..., Any], **kwargs: Any) -> dict[str, Any]:
try: try:
response = await asyncio.to_thread(method, **kwargs) response = await asyncio.to_thread(method, **kwargs)
@@ -239,3 +487,35 @@ def _is_protective_order(order: dict[str, Any]) -> bool:
return True return True
stop_type = str(order.get("stopOrderType") or "").lower() stop_type = str(order.get("stopOrderType") or "").lower()
return any(marker in stop_type for marker in ("takeprofit", "stoploss", "trailing", "oco")) 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),
),
}

View File

@@ -11,7 +11,7 @@ from llm_trade_bot.service import TradeBotService
def main() -> None: 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("--config", default="config.toml", help="Path to TOML configuration")
parser.add_argument("--log-level", default="INFO", choices=("DEBUG", "INFO", "WARNING", "ERROR")) parser.add_argument("--log-level", default="INFO", choices=("DEBUG", "INFO", "WARNING", "ERROR"))
args = parser.parse_args() args = parser.parse_args()

View File

@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
import tomllib import tomllib
from dataclasses import dataclass from dataclasses import dataclass, field
from datetime import time from datetime import time
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -28,6 +28,23 @@ class BybitConfig:
settle_coin: str = "USDT" 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) @dataclass(frozen=True, slots=True)
class TelegramConfig: class TelegramConfig:
api_id: int api_id: int
@@ -83,7 +100,7 @@ class StorageConfig:
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class AppConfig: class AppConfig:
deepseek: DeepSeekConfig deepseek: DeepSeekConfig
bybit: BybitConfig bybit: BybitConfig | None
telegram: TelegramConfig telegram: TelegramConfig
brave: BraveConfig brave: BraveConfig
prompts: PromptsConfig prompts: PromptsConfig
@@ -91,6 +108,8 @@ class AppConfig:
scanner: ScannerConfig scanner: ScannerConfig
risk: RiskConfig risk: RiskConfig
storage: StorageConfig storage: StorageConfig
exchange: ExchangeConfig = field(default_factory=ExchangeConfig)
binance: BinanceConfig | None = None
def load_config(path: str | Path) -> AppConfig: 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 raise ConfigError(f"Invalid TOML in {config_path}: {error}") from error
deepseek_raw = _section(raw, "deepseek") 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") telegram_raw = _section(raw, "telegram")
brave_raw = _section(raw, "brave") brave_raw = _section(raw, "brave")
prompts_raw = _section(raw, "prompts") prompts_raw = _section(raw, "prompts")
@@ -122,11 +152,26 @@ def load_config(path: str | Path) -> AppConfig:
if deepseek.base_url != "https://api.deepseek.com": if deepseek.base_url != "https://api.deepseek.com":
raise ConfigError("deepseek.base_url must be https://api.deepseek.com") raise ConfigError("deepseek.base_url must be https://api.deepseek.com")
bybit = BybitConfig( bybit: BybitConfig | None = None
api_key=_secret(bybit_raw, "api_key"), binance: BinanceConfig | None = None
api_secret=_secret(bybit_raw, "api_secret"), if exchange.provider == "bybit":
settle_coin=_string(bybit_raw, "settle_coin", "USDT").upper(), 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") 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): 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=_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), 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]: def _section(raw: dict[str, Any], name: str) -> dict[str, Any]:

View File

@@ -93,12 +93,15 @@ class Database:
CREATE TABLE IF NOT EXISTS managed_orders ( CREATE TABLE IF NOT EXISTS managed_orders (
order_link_id TEXT PRIMARY KEY, order_link_id TEXT PRIMARY KEY,
order_id TEXT, order_id TEXT,
provider TEXT NOT NULL DEFAULT 'bybit',
category TEXT NOT NULL, category TEXT NOT NULL,
symbol TEXT NOT NULL, symbol TEXT NOT NULL,
side TEXT NOT NULL, side TEXT NOT NULL,
status TEXT NOT NULL, status TEXT NOT NULL,
expires_at TEXT, expires_at TEXT,
trailing_distance TEXT, trailing_distance TEXT,
entry_order_id TEXT,
protection_order_ids_json TEXT NOT NULL DEFAULT '[]',
raw_json TEXT NOT NULL, raw_json TEXT NOT NULL,
updated_at 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() await self.connection.commit()
async def add_note(self, text: str) -> int: async def add_note(self, text: str) -> int:
@@ -244,52 +259,74 @@ class Database:
expires_at: datetime | None, expires_at: datetime | None,
trailing_distance: str | None, trailing_distance: str | None,
raw: Any, raw: Any,
provider: str = "bybit",
entry_order_id: str | None = None,
protection_order_ids: list[str] | None = None,
) -> None: ) -> None:
raw_json = json.dumps(raw, ensure_ascii=False, default=str, separators=(",", ":")) raw_json = json.dumps(raw, ensure_ascii=False, default=str, separators=(",", ":"))
protection_json = json.dumps(protection_order_ids or [], separators=(",", ":"))
await self.connection.execute( await self.connection.execute(
""" """
INSERT INTO managed_orders( INSERT INTO managed_orders(
order_link_id, order_id, category, symbol, side, status, order_link_id, order_id, provider, category, symbol, side, status,
expires_at, trailing_distance, raw_json, updated_at expires_at, trailing_distance, entry_order_id, protection_order_ids_json,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) raw_json, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(order_link_id) DO UPDATE SET 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, 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 raw_json=excluded.raw_json, updated_at=excluded.updated_at
""", """,
( (
order_link_id, order_link_id,
order_id, order_id,
provider,
category, category,
symbol, symbol,
side, side,
status, status,
expires_at.isoformat() if expires_at else None, expires_at.isoformat() if expires_at else None,
trailing_distance, trailing_distance,
entry_order_id,
protection_json,
raw_json, raw_json,
utc_now().isoformat(), utc_now().isoformat(),
), ),
) )
await self.connection.commit() 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( cursor = await self.connection.execute(
""" f"""
SELECT order_link_id, order_id, category, symbol FROM managed_orders 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<=? 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()] 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( cursor = await self.connection.execute(
""" f"""
SELECT order_link_id, order_id, category, symbol, trailing_distance SELECT order_link_id, order_id, provider, category, symbol, trailing_distance
FROM managed_orders FROM managed_orders
WHERE category='linear' AND trailing_distance IS NOT NULL WHERE category='linear' AND trailing_distance IS NOT NULL
AND status IN ('Created', 'New', 'PartiallyFilled') AND status IN ('Created', 'New', 'PartiallyFilled')
""" {provider_clause}
""",
(provider,) if provider else (),
) )
return [dict(row) for row in await cursor.fetchall()] return [dict(row) for row in await cursor.fetchall()]

View File

@@ -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)

View File

@@ -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

View File

@@ -3,16 +3,13 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import logging import logging
from contextlib import suppress
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Any from typing import Any
from pybit.unified_trading import WebSocket
from llm_trade_bot.agent import AgentRunner 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.config import AppConfig
from llm_trade_bot.database import Database 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.scheduler import next_scheduled_time
from llm_trade_bot.telegram_bot import TelegramBot from llm_trade_bot.telegram_bot import TelegramBot
@@ -23,27 +20,27 @@ class TradeBotService:
def __init__(self, config: AppConfig): def __init__(self, config: AppConfig):
self.config = config self.config = config
self.database = Database(config.storage.database_path) self.database = Database(config.storage.database_path)
self.bybit = BybitGateway(config.bybit, config.scanner) self.exchange = create_exchange_gateway(config)
self.agent = AgentRunner(config, self.database, self.bybit) self.bybit = self.exchange # compatibility with Telegram extensions
self.agent = AgentRunner(config, self.database, self.exchange)
self.telegram = TelegramBot(config.telegram, self) self.telegram = TelegramBot(config.telegram, self)
self._run_lock = asyncio.Lock() self._run_lock = asyncio.Lock()
self._paused = False self._paused = False
self._tasks: set[asyncio.Task[Any]] = set() self._tasks: set[asyncio.Task[Any]] = set()
self._stopping = asyncio.Event() self._stopping = asyncio.Event()
self._websocket: WebSocket | None = None
self._loop: asyncio.AbstractEventLoop | None = None
async def start(self) -> None: async def start(self) -> None:
await self.database.open() await self.database.open()
self._paused = (await self.database.get_state("paused", "false")) == "true" self._paused = (await self.database.get_state("paused", "false")) == "true"
await self.bybit.startup_check() await self.exchange.startup_check()
await self.telegram.start() await self.telegram.start()
self._loop = asyncio.get_running_loop() self._spawn(self.exchange.private_stream(self._record_exchange_event, self._stopping), "private-stream")
self._start_private_websocket()
self._spawn(self._scheduler_loop(), "scheduler") self._spawn(self._scheduler_loop(), "scheduler")
self._spawn(self._outbox_loop(), "outbox") self._spawn(self._outbox_loop(), "outbox")
self._spawn(self._reconcile_loop(), "reconciler") 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: async def stop(self) -> None:
self._stopping.set() self._stopping.set()
@@ -51,11 +48,14 @@ class TradeBotService:
task.cancel() task.cancel()
if self._tasks: if self._tasks:
await asyncio.gather(*self._tasks, return_exceptions=True) await asyncio.gather(*self._tasks, return_exceptions=True)
if self._websocket is not None: try:
with suppress(Exception):
self._websocket.exit()
with suppress(Exception):
await self.telegram.stop() 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() await self.database.close()
async def wait(self) -> None: async def wait(self) -> None:
@@ -95,7 +95,7 @@ class TradeBotService:
async def pause(self) -> str: async def pause(self) -> str:
self._paused = True self._paused = True
await self.database.set_state("paused", "true") await self.database.set_state("paused", "true")
return "Новые анализы и входы приостановлены. Защитные ордера Bybit остаются активны." return f"Новые анализы и входы приостановлены. Защитные ордера {self.exchange.provider} остаются активны."
async def resume(self) -> str: async def resume(self) -> str:
self._paused = False self._paused = False
@@ -106,7 +106,8 @@ class TradeBotService:
next_run = next_scheduled_time(self.config.schedule) next_run = next_scheduled_time(self.config.schedule)
last = await self.database.last_run() last = await self.database.last_run()
return ( return (
f"Режим: Bybit Demo\n" f"Биржа: {self.exchange.provider}\n"
f"Среда: {self.exchange.environment}\n"
f"Состояние: {'пауза' if self._paused else 'активен'}\n" f"Состояние: {'пауза' if self._paused else 'активен'}\n"
f"Агент: {'работает' if self._run_lock.locked() else 'ожидает'}\n" f"Агент: {'работает' if self._run_lock.locked() else 'ожидает'}\n"
f"Следующий запуск: {next_run.isoformat()}\n" f"Следующий запуск: {next_run.isoformat()}\n"
@@ -114,13 +115,13 @@ class TradeBotService:
) )
async def positions_text(self) -> str: async def positions_text(self) -> str:
state = await self.bybit.account_state() state = await self.exchange.account_state()
return "Позиции и активы:\n" + json.dumps( 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] )[:3800]
async def orders_text(self) -> str: 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] return "Открытые ордера:\n" + json.dumps(orders, ensure_ascii=False, default=str)[:3800]
async def last_run_text(self) -> str: async def last_run_text(self) -> str:
@@ -164,19 +165,25 @@ class TradeBotService:
while not self._stopping.is_set(): while not self._stopping.is_set():
try: try:
await self._cancel_expired_orders() 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: except Exception:
LOGGER.exception("Bybit reconciliation failed") LOGGER.exception("%s reconciliation failed", self.exchange.provider)
try: try:
await asyncio.wait_for(self._stopping.wait(), timeout=60) await asyncio.wait_for(self._stopping.wait(), timeout=60)
except TimeoutError: except TimeoutError:
pass pass
async def _cancel_expired_orders(self) -> None: 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: try:
await self.bybit.cancel_order( await self.exchange.cancel_order(
category=order["category"], symbol=order["symbol"], orderId=order["order_id"] 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.mark_managed_order_status(order["order_link_id"], "Cancelled")
await self.database.queue_message( await self.database.queue_message(
@@ -187,8 +194,8 @@ class TradeBotService:
raise raise
async def _apply_pending_trailing_stops(self) -> None: async def _apply_pending_trailing_stops(self) -> None:
for managed in await self.database.managed_orders_with_pending_trailing(): for managed in await self.database.managed_orders_with_pending_trailing(provider="bybit"):
history = await self.bybit.order_history("linear", 50) history = await self.exchange.order_history("linear", 50)
matching = next( matching = next(
(item for item in history if item.get("orderLinkId") == managed["order_link_id"]), None (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"] trailing_distance = managed["trailing_distance"]
if order_link_id is None or symbol is None or trailing_distance is None: if order_link_id is None or symbol is None or trailing_distance is None:
continue continue
await self.bybit.set_trading_stop( await self.exchange.set_trading_stop( # type: ignore[attr-defined]
category="linear", category="linear",
symbol=symbol, symbol=symbol,
positionIdx=0, positionIdx=0,
@@ -211,38 +218,25 @@ class TradeBotService:
f"🛡 Trailing-stop установлен: {symbol} distance={trailing_distance}" f"🛡 Trailing-stop установлен: {symbol} distance={trailing_distance}"
) )
def _start_private_websocket(self) -> None: async def _record_exchange_event(self, message: dict[str, Any]) -> None:
websocket = WebSocket( kind = str(message.get("kind") or "event")
testnet=False, await self.database.add_event(f"{self.exchange.provider}_ws_{kind}", message)
demo=True, if kind == "reconcile_required":
channel_type="private", for event in await self.exchange.reconcile():
api_key=self.config.bybit.api_key, await self.database.add_event(f"{self.exchange.provider}_reconcile", event)
api_secret=self.config.bybit.api_secret, return
) client_id = message.get("client_id")
websocket.order_stream(callback=self._websocket_callback("order")) status = message.get("status")
websocket.execution_stream(callback=self._websocket_callback("execution")) if isinstance(client_id, str) and client_id.startswith("ltb-") and status:
websocket.position_stream(callback=self._websocket_callback("position")) await self.database.mark_managed_order_status(client_id, str(status))
websocket.wallet_stream(callback=self._websocket_callback("wallet")) if self.exchange.provider == "bybit" and kind == "order":
self._websocket = websocket raw = message.get("raw", {})
for order in raw.get("data", []) if isinstance(raw, dict) else []:
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", []):
order_link_id = order.get("orderLinkId") order_link_id = order.get("orderLinkId")
order_status = order.get("orderStatus") order_status = order.get("orderStatus")
if order_link_id and order_link_id.startswith("ltb-") and order_status: 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) await self.database.mark_managed_order_status(order_link_id, order_status)
if kind in {"order", "execution"}: if kind in {"order", "execution"}:
await self.database.queue_message( 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]}"
) )

View File

@@ -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&timestamp=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"))

View File

@@ -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

View File

@@ -75,3 +75,66 @@ def test_rejects_missing_prompts_file(tmp_path: Path):
path.write_text(CONFIG, encoding="utf-8") path.write_text(CONFIG, encoding="utf-8")
with pytest.raises(ConfigError, match="Prompts file not found"): with pytest.raises(ConfigError, match="Prompts file not found"):
load_config(path) 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))

View File

@@ -1,3 +1,4 @@
import sqlite3
from pathlib import Path from pathlib import Path
from llm_trade_bot.database import Database from llm_trade_bot.database import Database
@@ -35,3 +36,44 @@ async def test_run_lifecycle(tmp_path: Path):
assert last["summary"] == "idle" assert last["summary"] == "idle"
finally: finally:
await database.close() 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()

79
uv.lock generated
View File

@@ -411,6 +411,7 @@ dependencies = [
{ name = "pybit" }, { name = "pybit" },
{ name = "tgcrypto" }, { name = "tgcrypto" },
{ name = "tzdata" }, { name = "tzdata" },
{ name = "websockets" },
] ]
[package.dev-dependencies] [package.dev-dependencies]
@@ -430,6 +431,7 @@ requires-dist = [
{ name = "pybit", specifier = ">=5.12,<6" }, { name = "pybit", specifier = ">=5.12,<6" },
{ name = "tgcrypto", specifier = ">=1.2.5,<2" }, { name = "tgcrypto", specifier = ">=1.2.5,<2" },
{ name = "tzdata", specifier = ">=2026.1" }, { name = "tzdata", specifier = ">=2026.1" },
{ name = "websockets", specifier = ">=15,<17" },
] ]
[package.metadata.requires-dev] [package.metadata.requires-dev]
@@ -831,3 +833,80 @@ sdist = { url = "https://files.pythonhosted.org/packages/d8/cb/a5abcc2891249f393
wheels = [ 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" }, { 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" },
]