Add Binance Testnet exchange gateway
This commit is contained in:
27
README.md
27
README.md
@@ -1,27 +1,32 @@
|
||||
# LLM Trade Bot
|
||||
|
||||
Автономный DeepSeek-агент для выделенного **Bybit Demo Trading** аккаунта. Бот анализирует Spot и Linear USDT-рынки два раза в день, умеет искать рыночный контекст в интернете и управляется через Telegram.
|
||||
Автономный DeepSeek-агент для выделенного **Bybit Demo Trading** либо **Binance Testnet** аккаунта. Один процесс работает только с одной биржей, выбранной в `config.toml`; одновременная торговля на обеих не поддерживается. Бот анализирует Spot и `linear` USDT-рынки два раза в день, умеет искать рыночный контекст в интернете и управляется через Telegram.
|
||||
|
||||
Сервис принципиально не поддерживает production endpoint Bybit. Размер позиции и все лимиты проверяются обычным кодом непосредственно перед отправкой ордера.
|
||||
`linear` — публичное имя USDⓈ-M Futures: оно намеренно сохранено в инструментах агента и промптах. Production endpoints не поддерживаются. Для Binance адреса Spot Testnet и USDⓈ-M Futures Demo Testnet зафиксированы в коде и не настраиваются через TOML.
|
||||
|
||||
## Возможности
|
||||
|
||||
- новая stateless-сессия `micro-agents` для каждого анализа;
|
||||
- долговременные `note` и односторонние `ask_admin` через SQLite/Telegram;
|
||||
- общий exchange gateway с нормализованными ордерами, сделками и приватными WebSocket-событиями;
|
||||
- сканер ликвидных Spot и Linear инструментов, свечи 1h/4h/1d, стакан и индикаторы;
|
||||
- Brave Search и безопасное чтение выбранных публичных HTTPS-страниц;
|
||||
- максимум 2% equity риска на идею, 40% gross exposure, пять идей и 3x Linear leverage;
|
||||
- обязательный exchange-side stop-loss, TP или trailing для Linear, TP/SL для Spot;
|
||||
- Telegram-команды, приватный Bybit WebSocket, аудит и восстановление состояния через REST.
|
||||
- на Binance Spot — одна атомарная OTOCO-конструкция: Limit-вход и ожидающая рыночная TP/SL OCO-пара; если Testnet её отклоняет, вход не создаётся;
|
||||
- на Binance USDⓈ-M — one-way + isolated margin, плечо перед входом и обязательный stop через `/fapi/v1/algoOrder`; при ошибке установки stop-loss позиция аварийно закрывается;
|
||||
- отдельные снимки и лимиты 2%/40% для Spot и Futures кошельков Binance, при общем лимите пяти идей;
|
||||
- Telegram-команды, приватные WebSocket-потоки, аудит и REST-reconciliation.
|
||||
|
||||
## Подготовка
|
||||
|
||||
1. Создайте ключ в основном аккаунте Bybit после переключения в **Demo Trading**, а не на testnet.
|
||||
2. Настройте Unified account в `ISOLATED_MARGIN` и one-way position mode. Для Spot margin borrowing не используется.
|
||||
3. Создайте Telegram-бота и получите `api_id`/`api_hash` приложения Telegram.
|
||||
4. Получите DeepSeek API key и Brave Search API key.
|
||||
5. Скопируйте `config.example.toml` в `config.toml`, а `prompts.example.md` в `prompts.md`.
|
||||
6. Замените секреты в `config.toml`. Локальные конфиги исключены из Git.
|
||||
1. Выберите `exchange.provider` в `config.toml`.
|
||||
2. Для Bybit создайте ключ в основном аккаунте после переключения в **Demo Trading**, а не на testnet. Настройте Unified account в `ISOLATED_MARGIN` и one-way position mode.
|
||||
3. Для Binance создайте два отдельных набора ключей: Spot Testnet на [testnet.binance.vision](https://testnet.binance.vision/) и USDⓈ-M Futures Testnet/Demo. У ключей должны быть права чтения и торговли. Futures-аккаунт должен использовать one-way mode; бот проверит это при запуске и выставит isolated margin/плечо перед входом.
|
||||
4. Создайте Telegram-бота и получите `api_id`/`api_hash` приложения Telegram.
|
||||
5. Получите DeepSeek API key и Brave Search API key.
|
||||
6. Скопируйте `config.example.toml` в `config.toml`, а `prompts.example.md` в `prompts.md`.
|
||||
7. Замените секреты только выбранного провайдера. Локальные конфиги исключены из Git.
|
||||
|
||||
```powershell
|
||||
Copy-Item config.example.toml config.toml
|
||||
@@ -50,7 +55,7 @@ uv run llm-trade-bot --config config.toml
|
||||
- `/run` для внепланового анализа;
|
||||
- `/help`.
|
||||
|
||||
`/pause` запрещает новые агентские сессии и входы, но уже размещённые защитные ордера остаются на Bybit.
|
||||
`/pause` запрещает новые агентские сессии и входы, но уже размещённые защитные ордера остаются на выбранной бирже.
|
||||
|
||||
## Тесты
|
||||
|
||||
@@ -58,4 +63,4 @@ uv run llm-trade-bot --config config.toml
|
||||
uv run pytest
|
||||
```
|
||||
|
||||
Тесты не используют реальные ключи и не создают ордера. Перед первым полноценным запуском рекомендуется пополнить Demo Trading только USDT и проверить `/status`, `/positions` и `/orders`.
|
||||
Тесты не используют реальные ключи и не создают ордера. Перед первым полноценным запуском проверьте `/status`, `/positions` и `/orders` на выбранном Testnet/Demo-кошельке. Для Binance отдельно проверьте Spot Limit OTOCO и Futures Market-вход с защитой.
|
||||
|
||||
@@ -5,11 +5,27 @@ model = "deepseek-flash"
|
||||
thinking = true
|
||||
max_tool_rounds = 256
|
||||
|
||||
# Choose exactly one provider for this process. Binance is permanently restricted
|
||||
# to its Spot and USDⓈ-M Futures Testnet endpoints in application code.
|
||||
[exchange]
|
||||
provider = "binance" # binance | bybit
|
||||
|
||||
[bybit]
|
||||
api_key = "replace-me"
|
||||
api_secret = "replace-me"
|
||||
settle_coin = "USDT"
|
||||
|
||||
[binance.spot]
|
||||
# Create these keys at https://testnet.binance.vision/ . They are not shared
|
||||
# with the USDⓈ-M Futures demo environment below.
|
||||
api_key = "replace-me"
|
||||
api_secret = "replace-me"
|
||||
|
||||
[binance.usdm]
|
||||
# Create these keys in the USDⓈ-M Futures Testnet/Demo environment.
|
||||
api_key = "replace-me"
|
||||
api_secret = "replace-me"
|
||||
|
||||
[telegram]
|
||||
api_id = 123456
|
||||
api_hash = "replace-me"
|
||||
|
||||
@@ -17,6 +17,7 @@ dependencies = [
|
||||
"pybit>=5.12,<6",
|
||||
"tgcrypto>=1.2.5,<2",
|
||||
"tzdata>=2026.1",
|
||||
"websockets>=15,<17",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
||||
@@ -24,9 +24,9 @@ from micro_agents import (
|
||||
ToolManager,
|
||||
)
|
||||
|
||||
from llm_trade_bot.bybit_gateway import BybitError, BybitGateway
|
||||
from llm_trade_bot.config import AppConfig
|
||||
from llm_trade_bot.database import Database, utc_now
|
||||
from llm_trade_bot.exchange_gateway import ExchangeGateway, normalized_order
|
||||
from llm_trade_bot.models import Category, OpenTradeRequest, OrderType, Side
|
||||
from llm_trade_bot.prompts import PromptStore
|
||||
from llm_trade_bot.risk import RiskEngine
|
||||
@@ -34,10 +34,11 @@ from llm_trade_bot.web_tools import SafeWebTools
|
||||
|
||||
|
||||
class AgentTools:
|
||||
def __init__(self, config: AppConfig, database: Database, bybit: BybitGateway):
|
||||
def __init__(self, config: AppConfig, database: Database, exchange: ExchangeGateway):
|
||||
self.config = config
|
||||
self.database = database
|
||||
self.bybit = bybit
|
||||
self.exchange = exchange
|
||||
self.bybit = exchange # compatibility for integrations that inspected this attribute
|
||||
self.risk = RiskEngine(config.risk)
|
||||
self.web = SafeWebTools(config.brave.api_key)
|
||||
self._dedup: dict[str, dict[str, Any]] = {}
|
||||
@@ -70,7 +71,7 @@ class AgentTools:
|
||||
|
||||
async def get_account_state(self) -> dict[str, Any]:
|
||||
"""Return current wallet, spot assets, linear positions, and open orders."""
|
||||
return await self.bybit.account_state()
|
||||
return await self.exchange.account_state()
|
||||
|
||||
async def get_market_data(
|
||||
self,
|
||||
@@ -80,19 +81,19 @@ class AgentTools:
|
||||
) -> dict[str, Any]:
|
||||
"""Return ticker, order book, candles, and indicators for a current scanner candidate."""
|
||||
self._require_candidate(category, symbol, context)
|
||||
return await self.bybit.market_context(category, symbol)
|
||||
return await self.exchange.market_context(category, symbol)
|
||||
|
||||
async def get_open_orders(self, category: str | None = None) -> list[dict[str, Any]]:
|
||||
"""Return active normal and protective orders."""
|
||||
return await self.bybit.open_orders(category)
|
||||
return await self.exchange.open_orders(category)
|
||||
|
||||
async def get_order_history(self, category: Literal["spot", "linear"], limit: int = 20) -> list[dict[str, Any]]:
|
||||
"""Return recent Bybit order history for spot or linear."""
|
||||
return await self.bybit.order_history(category, limit)
|
||||
"""Return recent normalized order history for spot or linear."""
|
||||
return await self.exchange.order_history(category, limit)
|
||||
|
||||
async def get_trade_history(self, category: Literal["spot", "linear"], limit: int = 20) -> list[dict[str, Any]]:
|
||||
"""Return recent executions for spot or linear."""
|
||||
return await self.bybit.executions(category, limit)
|
||||
return await self.exchange.executions(category, limit)
|
||||
|
||||
async def open_trade(
|
||||
self,
|
||||
@@ -125,11 +126,11 @@ class AgentTools:
|
||||
category_value = Category(category)
|
||||
side_value = Side(side)
|
||||
order_type_value = OrderType(order_type)
|
||||
state = await self.bybit.account_state()
|
||||
state = await self.exchange.account_state()
|
||||
if _has_existing_idea(state, category, symbol):
|
||||
raise ValueError(f"An active position or opening order already exists for {category}:{symbol}")
|
||||
|
||||
market = await self.bybit.market_context(category, symbol)
|
||||
market = await self.exchange.market_context(category, symbol)
|
||||
ticker = market.get("ticker", {})
|
||||
reference_price = Decimal(str(ticker.get("lastPrice") or "0"))
|
||||
request = OpenTradeRequest(
|
||||
@@ -146,48 +147,78 @@ class AgentTools:
|
||||
leverage=Decimal(str(leverage)),
|
||||
rationale=rationale[:1000],
|
||||
)
|
||||
account = await self.bybit.account_snapshot()
|
||||
rules = await self.bybit.instrument_rules(category, symbol)
|
||||
try:
|
||||
account = await self.exchange.account_snapshot(category)
|
||||
except TypeError: # old test doubles and third-party Bybit adapters
|
||||
account = await self.exchange.account_snapshot()
|
||||
rules = await self.exchange.instrument_rules(category, symbol)
|
||||
validated = self.risk.validate_open(request, account, rules)
|
||||
|
||||
if category_value is Category.LINEAR:
|
||||
try:
|
||||
await self.bybit.set_leverage(symbol.upper(), str(validated.leverage))
|
||||
except BybitError as error:
|
||||
if "not modified" not in str(error).lower() and "110043" not in str(error):
|
||||
raise
|
||||
|
||||
order_link_id = f"ltb-{run_id}-{uuid4().hex[:12]}"[:36]
|
||||
parameters: dict[str, Any] = {
|
||||
"category": category,
|
||||
"symbol": symbol.upper(),
|
||||
"side": side,
|
||||
"orderType": order_type,
|
||||
"qty": _plain(validated.quantity),
|
||||
"orderLinkId": order_link_id,
|
||||
}
|
||||
if order_type_value is OrderType.LIMIT:
|
||||
parameters.update(price=_plain(validated.entry_price), timeInForce="GTC")
|
||||
if category_value is Category.SPOT:
|
||||
parameters.update(
|
||||
isLeverage=0,
|
||||
takeProfit=_plain(validated.take_profit),
|
||||
stopLoss=_plain(validated.stop_loss),
|
||||
tpOrderType="Market",
|
||||
slOrderType="Market",
|
||||
)
|
||||
if hasattr(self.exchange, "open_trade"):
|
||||
try:
|
||||
result = await self.exchange.open_trade(request, validated, order_link_id)
|
||||
except Exception as error:
|
||||
if "emergency-closed" in str(error):
|
||||
await self.database.queue_message(
|
||||
f"🚨 {self.exchange.provider}: обязательный stop-loss не установлен; "
|
||||
f"позиция {symbol.upper()} аварийно закрыта. {error}"
|
||||
)
|
||||
raise
|
||||
else:
|
||||
parameters.update(
|
||||
positionIdx=0,
|
||||
stopLoss=_plain(validated.stop_loss),
|
||||
tpslMode="Partial",
|
||||
slOrderType="Market",
|
||||
# Transitional compatibility for simple legacy gateways.
|
||||
if category_value is Category.LINEAR:
|
||||
try:
|
||||
await self.exchange.set_leverage(symbol.upper(), str(validated.leverage)) # type: ignore[attr-defined]
|
||||
except Exception as error:
|
||||
if "not modified" not in str(error).lower() and "110043" not in str(error):
|
||||
raise
|
||||
parameters: dict[str, Any] = {
|
||||
"category": category,
|
||||
"symbol": symbol.upper(),
|
||||
"side": side,
|
||||
"orderType": order_type,
|
||||
"qty": _plain(validated.quantity),
|
||||
"orderLinkId": order_link_id,
|
||||
}
|
||||
if order_type_value is OrderType.LIMIT:
|
||||
parameters.update(price=_plain(validated.entry_price), timeInForce="GTC")
|
||||
if category_value is Category.SPOT:
|
||||
parameters.update(
|
||||
isLeverage=0,
|
||||
takeProfit=_plain(validated.take_profit),
|
||||
stopLoss=_plain(validated.stop_loss),
|
||||
tpOrderType="Market",
|
||||
slOrderType="Market",
|
||||
)
|
||||
else:
|
||||
parameters.update(
|
||||
positionIdx=0,
|
||||
stopLoss=_plain(validated.stop_loss),
|
||||
tpslMode="Partial",
|
||||
slOrderType="Market",
|
||||
)
|
||||
if validated.take_profit is not None:
|
||||
parameters.update(takeProfit=_plain(validated.take_profit), tpOrderType="Market")
|
||||
response = await self.exchange.place_order(**parameters) # type: ignore[attr-defined]
|
||||
raw_result = response.get("result", {})
|
||||
result = normalized_order(
|
||||
provider=getattr(self.exchange, "provider", "bybit"),
|
||||
category=category,
|
||||
symbol=symbol.upper(),
|
||||
side=side,
|
||||
status="Created",
|
||||
quantity=_plain(validated.quantity),
|
||||
price=_plain(validated.entry_price),
|
||||
client_id=order_link_id,
|
||||
order_id=raw_result.get("orderId"),
|
||||
protection={
|
||||
"stop_loss": _plain(validated.stop_loss),
|
||||
"take_profit": _plain(validated.take_profit),
|
||||
"trailing_distance": _plain(validated.trailing_distance),
|
||||
},
|
||||
raw=response,
|
||||
)
|
||||
if validated.take_profit is not None:
|
||||
parameters.update(takeProfit=_plain(validated.take_profit), tpOrderType="Market")
|
||||
|
||||
response = await self.bybit.place_order(**parameters)
|
||||
result = response.get("result", {})
|
||||
expires_at = (
|
||||
utc_now() + timedelta(hours=self.config.risk.limit_order_ttl_hours)
|
||||
if order_type_value is OrderType.LIMIT
|
||||
@@ -195,18 +226,28 @@ class AgentTools:
|
||||
)
|
||||
await self.database.upsert_managed_order(
|
||||
order_link_id,
|
||||
order_id=str(result.get("orderId") or "") or None,
|
||||
order_id=str(result.get("order_id") or "") or None,
|
||||
provider=getattr(self.exchange, "provider", self.config.exchange.provider),
|
||||
category=category,
|
||||
symbol=symbol.upper(),
|
||||
side=side,
|
||||
status="Created",
|
||||
status=str(result.get("status") or "Created"),
|
||||
expires_at=expires_at,
|
||||
trailing_distance=_plain(validated.trailing_distance),
|
||||
raw={"request": parameters, "response": response, "rationale": rationale},
|
||||
entry_order_id=str(result.get("entry_id") or result.get("order_id") or "") or None,
|
||||
protection_order_ids=[str(item) for item in result.get("protection_ids", [])],
|
||||
raw={"result": result, "rationale": rationale},
|
||||
)
|
||||
output = {
|
||||
"ok": True,
|
||||
"order_id": result.get("orderId"),
|
||||
**result,
|
||||
"provider": getattr(self.exchange, "provider", self.config.exchange.provider),
|
||||
"category": category,
|
||||
"symbol": symbol.upper(),
|
||||
"side": side,
|
||||
"status": result.get("status", "Created"),
|
||||
"order_id": result.get("order_id"),
|
||||
"client_id": result.get("client_id") or order_link_id,
|
||||
"order_link_id": order_link_id,
|
||||
"quantity": _plain(validated.quantity),
|
||||
"notional": _plain(validated.notional),
|
||||
@@ -231,71 +272,27 @@ class AgentTools:
|
||||
"""Reduce or close an existing spot holding or linear position."""
|
||||
if not 0 < percent <= 100:
|
||||
raise ValueError("percent must be in (0, 100]")
|
||||
rules = await self.bybit.instrument_rules(category, symbol)
|
||||
quantity: Decimal
|
||||
side: str
|
||||
if category == "linear":
|
||||
positions = await self.bybit.positions()
|
||||
position = next((item for item in positions if item.get("symbol") == symbol.upper() and Decimal(str(item.get("size") or 0)) > 0), None)
|
||||
if position is None:
|
||||
raise ValueError("No linear position found")
|
||||
quantity = Decimal(str(position["size"])) * Decimal(str(percent)) / Decimal(100)
|
||||
side = "Sell" if position.get("side") == "Buy" else "Buy"
|
||||
elif category == "spot":
|
||||
for order in await self.bybit.open_orders("spot"):
|
||||
if order.get("symbol") != symbol.upper():
|
||||
continue
|
||||
stop_type = str(order.get("stopOrderType") or "").lower()
|
||||
order_filter = order.get("orderFilter")
|
||||
if order_filter in {"tpslOrder", "OcoOrder", "BidirectionalTpslOrder"} or any(
|
||||
marker in stop_type for marker in ("takeprofit", "stoploss", "trailing", "oco")
|
||||
):
|
||||
await self.bybit.cancel_order(
|
||||
category="spot",
|
||||
symbol=symbol.upper(),
|
||||
orderId=order.get("orderId"),
|
||||
orderFilter=order_filter or "tpslOrder",
|
||||
)
|
||||
state = await self.bybit.account_state()
|
||||
wallet = (state.get("wallet") or [{}])[0]
|
||||
base_coin = symbol.upper().removesuffix(self.config.scanner.quote_coin)
|
||||
coin = next((item for item in wallet.get("coin", []) if item.get("coin") == base_coin), None)
|
||||
if coin is None:
|
||||
raise ValueError("No spot holding found")
|
||||
quantity = Decimal(str(coin.get("walletBalance") or 0)) * Decimal(str(percent)) / Decimal(100)
|
||||
side = "Sell"
|
||||
else:
|
||||
raise ValueError("category must be spot or linear")
|
||||
quantity = (quantity // rules.qty_step) * rules.qty_step
|
||||
if quantity < rules.min_qty:
|
||||
raise ValueError("Close quantity is below instrument minimum")
|
||||
parameters: dict[str, Any] = {
|
||||
"category": category,
|
||||
"symbol": symbol.upper(),
|
||||
"side": side,
|
||||
"orderType": order_type,
|
||||
"qty": _plain(quantity),
|
||||
"orderLinkId": f"ltb-close-{uuid4().hex[:16]}"[:36],
|
||||
}
|
||||
if order_type == "Limit":
|
||||
if limit_price is None:
|
||||
raise ValueError("Limit close requires limit_price")
|
||||
parameters.update(price=str(limit_price), timeInForce="GTC")
|
||||
if category == "linear":
|
||||
parameters.update(reduceOnly=True, positionIdx=0)
|
||||
else:
|
||||
parameters.update(isLeverage=0, marketUnit="baseCoin")
|
||||
response = await self.bybit.place_order(**parameters)
|
||||
output = {"ok": True, "result": response.get("result", {}), "quantity": _plain(quantity)}
|
||||
client_id = f"ltb-close-{uuid4().hex[:16]}"[:32]
|
||||
result = await self.exchange.close_trade(
|
||||
category,
|
||||
symbol.upper(),
|
||||
percent,
|
||||
order_type,
|
||||
str(limit_price) if limit_price is not None else None,
|
||||
client_id,
|
||||
)
|
||||
output = {"ok": True, **result}
|
||||
run_id = int(str(context.get("run_id", 0))) if context else None
|
||||
await self.database.add_event("trade_close_accepted", output, run_id=run_id)
|
||||
await self.database.queue_message(_trade_message("Закрытие", category, symbol, side, output))
|
||||
await self.database.queue_message(
|
||||
_trade_message("Закрытие", category, symbol, str(result.get("side") or ""), output)
|
||||
)
|
||||
return output
|
||||
|
||||
async def cancel_order(self, category: str, symbol: str, order_id: str) -> dict[str, Any]:
|
||||
"""Cancel an active order by Bybit order ID."""
|
||||
response = await self.bybit.cancel_order(category=category, symbol=symbol.upper(), orderId=order_id)
|
||||
return {"ok": True, "result": response.get("result", {})}
|
||||
"""Cancel an active order by its exchange order ID."""
|
||||
response = await self.exchange.cancel_order(category, symbol.upper(), order_id=order_id)
|
||||
return {"ok": True, **response}
|
||||
|
||||
async def update_protection(
|
||||
self,
|
||||
@@ -307,20 +304,12 @@ class AgentTools:
|
||||
"""Update exchange-side protection for an existing linear position."""
|
||||
if take_profit is None and trailing_distance is None:
|
||||
raise ValueError("take_profit or trailing_distance is required")
|
||||
parameters: dict[str, Any] = {
|
||||
"category": "linear",
|
||||
"symbol": symbol.upper(),
|
||||
"positionIdx": 0,
|
||||
"tpslMode": "Full",
|
||||
"stopLoss": str(stop_loss),
|
||||
"slTriggerBy": "MarkPrice",
|
||||
}
|
||||
if take_profit is not None:
|
||||
parameters.update(takeProfit=str(take_profit), tpTriggerBy="MarkPrice")
|
||||
if trailing_distance is not None:
|
||||
parameters["trailingStop"] = str(trailing_distance)
|
||||
response = await self.bybit.set_trading_stop(**parameters)
|
||||
return {"ok": True, "result": response.get("result", {})}
|
||||
return await self.exchange.update_protection(
|
||||
symbol.upper(),
|
||||
str(stop_loss),
|
||||
str(take_profit) if take_profit is not None else None,
|
||||
str(trailing_distance) if trailing_distance is not None else None,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _require_candidate(category: str, symbol: str, context: SessionContext | None) -> None:
|
||||
@@ -358,17 +347,18 @@ class AuditSubscriber(EventSubscriber):
|
||||
|
||||
|
||||
class AgentRunner:
|
||||
def __init__(self, config: AppConfig, database: Database, bybit: BybitGateway):
|
||||
def __init__(self, config: AppConfig, database: Database, exchange: ExchangeGateway):
|
||||
self.config = config
|
||||
self.database = database
|
||||
self.bybit = bybit
|
||||
self.exchange = exchange
|
||||
self.bybit = exchange
|
||||
self.prompts = PromptStore(config.prompts.file_path)
|
||||
llm = OpenAIBasedLLM(
|
||||
api_url=config.deepseek.base_url,
|
||||
api_key=config.deepseek.api_key,
|
||||
model=config.deepseek.model,
|
||||
)
|
||||
self.agent_tools = AgentTools(config, database, bybit)
|
||||
self.agent_tools = AgentTools(config, database, exchange)
|
||||
self.aggregator = Aggregator(
|
||||
llm,
|
||||
ToolManager(self.agent_tools.tools()),
|
||||
@@ -391,6 +381,12 @@ class AgentRunner:
|
||||
runtime_context = json.dumps(
|
||||
{
|
||||
"trigger": trigger,
|
||||
"exchange": {
|
||||
"provider": self.exchange.provider,
|
||||
"environment": self.exchange.environment,
|
||||
"spot_entries": "Limit OTOCO" if self.exchange.provider == "binance" else "Limit with TP/SL",
|
||||
"linear_name": "USDⓈ-M Futures" if self.exchange.provider == "binance" else "Linear USDT",
|
||||
},
|
||||
"account": account,
|
||||
"scanner_candidates": candidates,
|
||||
"durable_notes": notes,
|
||||
@@ -428,7 +424,7 @@ class AgentRunner:
|
||||
raise
|
||||
|
||||
async def _initial_context(self) -> tuple[list[dict[str, str]], dict[str, Any], list[str]]:
|
||||
candidates_raw = await self.bybit.scan_markets()
|
||||
candidates_raw = await self.exchange.scan_markets()
|
||||
candidates = [
|
||||
{
|
||||
"category": item.category,
|
||||
@@ -439,7 +435,7 @@ class AgentRunner:
|
||||
}
|
||||
for item in candidates_raw
|
||||
]
|
||||
account = await self.bybit.account_state()
|
||||
account = await self.exchange.account_state()
|
||||
notes = await self.database.recent_notes(
|
||||
self.config.storage.max_notes, self.config.storage.max_notes_chars
|
||||
)
|
||||
@@ -464,7 +460,8 @@ def _signature(values: dict[str, Any], excluded: set[str]) -> str:
|
||||
def _has_existing_idea(state: dict[str, Any], category: str, symbol: str) -> bool:
|
||||
symbol = symbol.upper()
|
||||
if category == "linear" and any(
|
||||
item.get("symbol") == symbol and Decimal(str(item.get("size") or 0)) > 0
|
||||
item.get("symbol") == symbol
|
||||
and Decimal(str(item.get("size") or item.get("positionAmt") or 0)).copy_abs() > 0
|
||||
for item in state.get("positions", [])
|
||||
):
|
||||
return True
|
||||
@@ -476,10 +473,17 @@ def _has_existing_idea(state: dict[str, Any], category: str, symbol: str) -> boo
|
||||
for item in wallets[0].get("coin", [])
|
||||
):
|
||||
return True
|
||||
spot_balances = state.get("wallets", {}).get("spot", {}).get("balances", [])
|
||||
if any(
|
||||
item.get("asset") == base_coin
|
||||
and Decimal(str(item.get("free") or 0)) + Decimal(str(item.get("locked") or 0)) > 0
|
||||
for item in spot_balances
|
||||
):
|
||||
return True
|
||||
return any(
|
||||
item.get("symbol") == symbol
|
||||
and item.get("category", category) == category
|
||||
and not bool(item.get("reduceOnly"))
|
||||
and not bool(item.get("reduceOnly") or item.get("protective"))
|
||||
for item in state.get("open_orders", [])
|
||||
)
|
||||
|
||||
|
||||
241
src/llm_trade_bot/binance_client.py
Normal file
241
src/llm_trade_bot/binance_client.py
Normal 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))
|
||||
1053
src/llm_trade_bot/binance_gateway.py
Normal file
1053
src/llm_trade_bot/binance_gateway.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2,14 +2,24 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
from contextlib import suppress
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Any
|
||||
|
||||
from pybit.unified_trading import HTTP
|
||||
from pybit.unified_trading import HTTP, WebSocket
|
||||
|
||||
from llm_trade_bot.config import BybitConfig, ScannerConfig
|
||||
from llm_trade_bot.exchange_gateway import PrivateEventHandler, normalized_order
|
||||
from llm_trade_bot.market import technical_indicators
|
||||
from llm_trade_bot.models import AccountSnapshot, InstrumentRules, MarketCandidate
|
||||
from llm_trade_bot.models import (
|
||||
AccountSnapshot,
|
||||
Category,
|
||||
InstrumentRules,
|
||||
MarketCandidate,
|
||||
OpenTradeRequest,
|
||||
OrderType,
|
||||
ValidatedOrder,
|
||||
)
|
||||
|
||||
|
||||
class BybitError(RuntimeError):
|
||||
@@ -19,6 +29,9 @@ class BybitError(RuntimeError):
|
||||
class BybitGateway:
|
||||
"""Async facade over pybit's synchronous Unified Trading HTTP client."""
|
||||
|
||||
provider = "bybit"
|
||||
environment = "demo"
|
||||
|
||||
def __init__(self, config: BybitConfig, scanner: ScannerConfig):
|
||||
self.config = config
|
||||
self.scanner = scanner
|
||||
@@ -29,6 +42,9 @@ class BybitGateway:
|
||||
api_secret=config.api_secret,
|
||||
)
|
||||
|
||||
async def close(self) -> None:
|
||||
return None
|
||||
|
||||
async def startup_check(self) -> dict[str, Any]:
|
||||
account = await self._call(self._http.get_account_info)
|
||||
margin_mode = str(_result(account).get("marginMode", ""))
|
||||
@@ -62,10 +78,10 @@ class BybitGateway:
|
||||
for order in _result(response).get("list", []):
|
||||
order = dict(order)
|
||||
order.setdefault("category", item_category)
|
||||
result.append(order)
|
||||
result.append(_normalize_bybit_order(order, item_category))
|
||||
return result
|
||||
|
||||
async def account_snapshot(self) -> AccountSnapshot:
|
||||
async def account_snapshot(self, category: str | None = None) -> AccountSnapshot:
|
||||
wallet, positions, orders = await asyncio.gather(self.wallet(), self.positions(), self.open_orders())
|
||||
accounts = _result(wallet).get("list", [])
|
||||
if not accounts:
|
||||
@@ -101,6 +117,8 @@ class BybitGateway:
|
||||
async def account_state(self) -> dict[str, Any]:
|
||||
wallet, positions, orders = await asyncio.gather(self.wallet(), self.positions(), self.open_orders())
|
||||
return {
|
||||
"provider": self.provider,
|
||||
"environment": self.environment,
|
||||
"wallet": _result(wallet).get("list", []),
|
||||
"positions": positions,
|
||||
"open_orders": orders,
|
||||
@@ -154,6 +172,8 @@ class BybitGateway:
|
||||
"recent_candles": raw_candles[-20:],
|
||||
}
|
||||
return {
|
||||
"provider": self.provider,
|
||||
"environment": self.environment,
|
||||
"category": category,
|
||||
"symbol": symbol,
|
||||
"ticker": (_result(ticker_response).get("list") or [{}])[0],
|
||||
@@ -183,16 +203,44 @@ class BybitGateway:
|
||||
|
||||
async def order_history(self, category: str, limit: int = 20) -> list[dict[str, Any]]:
|
||||
response = await self._call(self._http.get_order_history, category=category, limit=min(limit, 50))
|
||||
return list(_result(response).get("list", []))
|
||||
return [_normalize_bybit_order(item, category) for item in _result(response).get("list", [])]
|
||||
|
||||
async def executions(self, category: str, limit: int = 20) -> list[dict[str, Any]]:
|
||||
response = await self._call(self._http.get_executions, category=category, limit=min(limit, 100))
|
||||
return list(_result(response).get("list", []))
|
||||
return [
|
||||
{
|
||||
**item,
|
||||
"provider": self.provider,
|
||||
"category": category,
|
||||
"symbol": item.get("symbol"),
|
||||
"side": item.get("side"),
|
||||
"quantity": str(item.get("execQty") or "0"),
|
||||
"price": str(item.get("execPrice") or "0"),
|
||||
"order_id": item.get("orderId"),
|
||||
"client_id": item.get("orderLinkId"),
|
||||
}
|
||||
for item in _result(response).get("list", [])
|
||||
]
|
||||
|
||||
async def place_order(self, **parameters: Any) -> dict[str, Any]:
|
||||
return await self._call(self._http.place_order, **parameters)
|
||||
|
||||
async def cancel_order(self, **parameters: Any) -> dict[str, Any]:
|
||||
async def cancel_order(
|
||||
self,
|
||||
category: str | None = None,
|
||||
symbol: str | None = None,
|
||||
order_id: str | None = None,
|
||||
client_id: str | None = None,
|
||||
**parameters: Any,
|
||||
) -> dict[str, Any]:
|
||||
if category is not None:
|
||||
parameters.setdefault("category", category)
|
||||
if symbol is not None:
|
||||
parameters.setdefault("symbol", symbol)
|
||||
if order_id is not None:
|
||||
parameters.setdefault("orderId", order_id)
|
||||
if client_id is not None:
|
||||
parameters.setdefault("orderLinkId", client_id)
|
||||
return await self._call(self._http.cancel_order, **parameters)
|
||||
|
||||
async def set_leverage(self, symbol: str, leverage: str) -> dict[str, Any]:
|
||||
@@ -207,6 +255,206 @@ class BybitGateway:
|
||||
async def set_trading_stop(self, **parameters: Any) -> dict[str, Any]:
|
||||
return await self._call(self._http.set_trading_stop, **parameters)
|
||||
|
||||
async def open_trade(
|
||||
self, request: OpenTradeRequest, order: ValidatedOrder, client_id: str
|
||||
) -> dict[str, Any]:
|
||||
if request.category is Category.LINEAR:
|
||||
try:
|
||||
await self.set_leverage(request.symbol, _plain(order.leverage))
|
||||
except BybitError as error:
|
||||
if "not modified" not in str(error).lower() and "110043" not in str(error):
|
||||
raise
|
||||
parameters: dict[str, Any] = {
|
||||
"category": request.category.value,
|
||||
"symbol": request.symbol,
|
||||
"side": request.side.value,
|
||||
"orderType": request.order_type.value,
|
||||
"qty": _plain(order.quantity),
|
||||
"orderLinkId": client_id,
|
||||
}
|
||||
if request.order_type is OrderType.LIMIT:
|
||||
parameters.update(price=_plain(order.entry_price), timeInForce="GTC")
|
||||
if request.category is Category.SPOT:
|
||||
parameters.update(
|
||||
isLeverage=0,
|
||||
takeProfit=_plain(order.take_profit),
|
||||
stopLoss=_plain(order.stop_loss),
|
||||
tpOrderType="Market",
|
||||
slOrderType="Market",
|
||||
)
|
||||
else:
|
||||
parameters.update(
|
||||
positionIdx=0,
|
||||
stopLoss=_plain(order.stop_loss),
|
||||
tpslMode="Partial",
|
||||
slOrderType="Market",
|
||||
)
|
||||
if order.take_profit is not None:
|
||||
parameters.update(takeProfit=_plain(order.take_profit), tpOrderType="Market")
|
||||
response = await self.place_order(**parameters)
|
||||
result = response.get("result", {})
|
||||
return normalized_order(
|
||||
provider=self.provider,
|
||||
category=request.category.value,
|
||||
symbol=request.symbol,
|
||||
side=request.side.value,
|
||||
status="Created",
|
||||
quantity=order.quantity,
|
||||
price=order.entry_price,
|
||||
client_id=client_id,
|
||||
order_id=result.get("orderId"),
|
||||
protective=False,
|
||||
protection={
|
||||
"stop_loss": _plain(order.stop_loss),
|
||||
"take_profit": _plain(order.take_profit),
|
||||
"trailing_distance": _plain(order.trailing_distance),
|
||||
},
|
||||
raw=response,
|
||||
)
|
||||
|
||||
async def close_trade(
|
||||
self,
|
||||
category: str,
|
||||
symbol: str,
|
||||
percent: float,
|
||||
order_type: str,
|
||||
limit_price: str | None,
|
||||
client_id: str,
|
||||
) -> dict[str, Any]:
|
||||
rules = await self.instrument_rules(category, symbol)
|
||||
if category == "linear":
|
||||
position = next(
|
||||
(
|
||||
item
|
||||
for item in await self.positions()
|
||||
if item.get("symbol") == symbol and _decimal(item.get("size")) > 0
|
||||
),
|
||||
None,
|
||||
)
|
||||
if position is None:
|
||||
raise BybitError("No linear position found")
|
||||
quantity = _decimal(position.get("size")) * Decimal(str(percent)) / Decimal(100)
|
||||
side = "Sell" if position.get("side") == "Buy" else "Buy"
|
||||
elif category == "spot":
|
||||
for active in await self.open_orders("spot"):
|
||||
if active.get("symbol") != symbol or not (
|
||||
active.get("protective") or _is_protective_order(active)
|
||||
):
|
||||
continue
|
||||
with suppress(BybitError):
|
||||
await self.cancel_order(
|
||||
category="spot",
|
||||
symbol=symbol,
|
||||
order_id=str(active.get("orderId") or "") or None,
|
||||
orderFilter=str(active.get("orderFilter") or "tpslOrder"),
|
||||
)
|
||||
state = await self.account_state()
|
||||
wallet = (state.get("wallet") or [{}])[0]
|
||||
base_coin = symbol.removesuffix(self.scanner.quote_coin)
|
||||
coin = next((item for item in wallet.get("coin", []) if item.get("coin") == base_coin), None)
|
||||
if coin is None:
|
||||
raise BybitError("No spot holding found")
|
||||
quantity = _decimal(coin.get("walletBalance")) * Decimal(str(percent)) / Decimal(100)
|
||||
side = "Sell"
|
||||
else:
|
||||
raise ValueError("category must be spot or linear")
|
||||
quantity = (quantity // rules.qty_step) * rules.qty_step
|
||||
parameters: dict[str, Any] = {
|
||||
"category": category,
|
||||
"symbol": symbol,
|
||||
"side": side,
|
||||
"orderType": order_type,
|
||||
"qty": _plain(quantity),
|
||||
"orderLinkId": client_id,
|
||||
}
|
||||
if order_type == "Limit":
|
||||
if limit_price is None:
|
||||
raise ValueError("Limit close requires limit_price")
|
||||
parameters.update(price=limit_price, timeInForce="GTC")
|
||||
if category == "linear":
|
||||
parameters.update(reduceOnly=True, positionIdx=0)
|
||||
else:
|
||||
parameters.update(isLeverage=0, marketUnit="baseCoin")
|
||||
response = await self.place_order(**parameters)
|
||||
result = response.get("result", {})
|
||||
return normalized_order(
|
||||
provider=self.provider,
|
||||
category=category,
|
||||
symbol=symbol,
|
||||
side=side,
|
||||
status="Created",
|
||||
quantity=quantity,
|
||||
price=limit_price or "0",
|
||||
client_id=client_id,
|
||||
order_id=result.get("orderId"),
|
||||
protective=category == "linear",
|
||||
raw=response,
|
||||
)
|
||||
|
||||
async def update_protection(
|
||||
self,
|
||||
symbol: str,
|
||||
stop_loss: str,
|
||||
take_profit: str | None,
|
||||
trailing_distance: str | None,
|
||||
) -> dict[str, Any]:
|
||||
parameters: dict[str, Any] = {
|
||||
"category": "linear",
|
||||
"symbol": symbol,
|
||||
"positionIdx": 0,
|
||||
"tpslMode": "Full",
|
||||
"stopLoss": stop_loss,
|
||||
"slTriggerBy": "MarkPrice",
|
||||
}
|
||||
if take_profit is not None:
|
||||
parameters.update(takeProfit=take_profit, tpTriggerBy="MarkPrice")
|
||||
if trailing_distance is not None:
|
||||
parameters["trailingStop"] = trailing_distance
|
||||
response = await self.set_trading_stop(**parameters)
|
||||
return {"ok": True, "provider": self.provider, "category": "linear", "raw": response}
|
||||
|
||||
async def private_stream(self, handler: PrivateEventHandler, stop_event: asyncio.Event) -> None:
|
||||
loop = asyncio.get_running_loop()
|
||||
queue: asyncio.Queue[tuple[str, dict[str, Any]]] = asyncio.Queue()
|
||||
|
||||
def callback(kind: str):
|
||||
def receive(message: dict[str, Any]) -> None:
|
||||
loop.call_soon_threadsafe(queue.put_nowait, (kind, message))
|
||||
|
||||
return receive
|
||||
|
||||
websocket = WebSocket(
|
||||
testnet=False,
|
||||
demo=True,
|
||||
channel_type="private",
|
||||
api_key=self.config.api_key,
|
||||
api_secret=self.config.api_secret,
|
||||
)
|
||||
websocket.order_stream(callback=callback("order"))
|
||||
websocket.execution_stream(callback=callback("execution"))
|
||||
websocket.position_stream(callback=callback("position"))
|
||||
websocket.wallet_stream(callback=callback("wallet"))
|
||||
try:
|
||||
while not stop_event.is_set():
|
||||
try:
|
||||
kind, message = await asyncio.wait_for(queue.get(), timeout=30)
|
||||
except TimeoutError:
|
||||
continue
|
||||
await handler(
|
||||
{
|
||||
"provider": self.provider,
|
||||
"category": _bybit_event_category(message),
|
||||
"kind": kind,
|
||||
"raw": message,
|
||||
}
|
||||
)
|
||||
finally:
|
||||
with suppress(Exception):
|
||||
websocket.exit()
|
||||
|
||||
async def reconcile(self) -> list[dict[str, Any]]:
|
||||
return []
|
||||
|
||||
async def _call(self, method: Callable[..., Any], **kwargs: Any) -> dict[str, Any]:
|
||||
try:
|
||||
response = await asyncio.to_thread(method, **kwargs)
|
||||
@@ -239,3 +487,35 @@ def _is_protective_order(order: dict[str, Any]) -> bool:
|
||||
return True
|
||||
stop_type = str(order.get("stopOrderType") or "").lower()
|
||||
return any(marker in stop_type for marker in ("takeprofit", "stoploss", "trailing", "oco"))
|
||||
|
||||
|
||||
def _plain(value: Any) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
return format(_decimal(value), "f")
|
||||
|
||||
|
||||
def _bybit_event_category(message: dict[str, Any]) -> str:
|
||||
data = message.get("data")
|
||||
if isinstance(data, list) and data and isinstance(data[0], dict):
|
||||
return str(data[0].get("category") or "unknown")
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _normalize_bybit_order(item: dict[str, Any], category: str) -> dict[str, Any]:
|
||||
return {
|
||||
**item,
|
||||
**normalized_order(
|
||||
provider="bybit",
|
||||
category=category,
|
||||
symbol=str(item.get("symbol") or ""),
|
||||
side=str(item.get("side") or ""),
|
||||
status=str(item.get("orderStatus") or "Unknown"),
|
||||
quantity=item.get("qty") or "0",
|
||||
filled_quantity=item.get("cumExecQty") or "0",
|
||||
price=item.get("avgPrice") or item.get("price") or "0",
|
||||
client_id=item.get("orderLinkId"),
|
||||
order_id=item.get("orderId"),
|
||||
protective=_is_protective_order(item),
|
||||
),
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ from llm_trade_bot.service import TradeBotService
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Autonomous DeepSeek trader for Bybit Demo")
|
||||
parser = argparse.ArgumentParser(description="Autonomous DeepSeek trader for Bybit Demo or Binance Testnet")
|
||||
parser.add_argument("--config", default="config.toml", help="Path to TOML configuration")
|
||||
parser.add_argument("--log-level", default="INFO", choices=("DEBUG", "INFO", "WARNING", "ERROR"))
|
||||
args = parser.parse_args()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tomllib
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -28,6 +28,23 @@ class BybitConfig:
|
||||
settle_coin: str = "USDT"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class BinanceAccountConfig:
|
||||
api_key: str
|
||||
api_secret: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class BinanceConfig:
|
||||
spot: BinanceAccountConfig
|
||||
usdm: BinanceAccountConfig
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ExchangeConfig:
|
||||
provider: str = "bybit"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TelegramConfig:
|
||||
api_id: int
|
||||
@@ -83,7 +100,7 @@ class StorageConfig:
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AppConfig:
|
||||
deepseek: DeepSeekConfig
|
||||
bybit: BybitConfig
|
||||
bybit: BybitConfig | None
|
||||
telegram: TelegramConfig
|
||||
brave: BraveConfig
|
||||
prompts: PromptsConfig
|
||||
@@ -91,6 +108,8 @@ class AppConfig:
|
||||
scanner: ScannerConfig
|
||||
risk: RiskConfig
|
||||
storage: StorageConfig
|
||||
exchange: ExchangeConfig = field(default_factory=ExchangeConfig)
|
||||
binance: BinanceConfig | None = None
|
||||
|
||||
|
||||
def load_config(path: str | Path) -> AppConfig:
|
||||
@@ -103,7 +122,18 @@ def load_config(path: str | Path) -> AppConfig:
|
||||
raise ConfigError(f"Invalid TOML in {config_path}: {error}") from error
|
||||
|
||||
deepseek_raw = _section(raw, "deepseek")
|
||||
bybit_raw = _section(raw, "bybit")
|
||||
exchange_raw = raw.get("exchange")
|
||||
if exchange_raw is None:
|
||||
if not isinstance(raw.get("bybit"), dict):
|
||||
raise ConfigError("Missing [exchange] section (legacy configs require [bybit])")
|
||||
exchange = ExchangeConfig("bybit")
|
||||
elif not isinstance(exchange_raw, dict):
|
||||
raise ConfigError("[exchange] must be a TOML table")
|
||||
else:
|
||||
provider = _string(exchange_raw, "provider").lower()
|
||||
if provider not in {"bybit", "binance"}:
|
||||
raise ConfigError("exchange.provider must be binance or bybit")
|
||||
exchange = ExchangeConfig(provider)
|
||||
telegram_raw = _section(raw, "telegram")
|
||||
brave_raw = _section(raw, "brave")
|
||||
prompts_raw = _section(raw, "prompts")
|
||||
@@ -122,11 +152,26 @@ def load_config(path: str | Path) -> AppConfig:
|
||||
if deepseek.base_url != "https://api.deepseek.com":
|
||||
raise ConfigError("deepseek.base_url must be https://api.deepseek.com")
|
||||
|
||||
bybit = BybitConfig(
|
||||
api_key=_secret(bybit_raw, "api_key"),
|
||||
api_secret=_secret(bybit_raw, "api_secret"),
|
||||
settle_coin=_string(bybit_raw, "settle_coin", "USDT").upper(),
|
||||
)
|
||||
bybit: BybitConfig | None = None
|
||||
binance: BinanceConfig | None = None
|
||||
if exchange.provider == "bybit":
|
||||
bybit_raw = _section(raw, "bybit")
|
||||
bybit = BybitConfig(
|
||||
api_key=_secret(bybit_raw, "api_key"),
|
||||
api_secret=_secret(bybit_raw, "api_secret"),
|
||||
settle_coin=_string(bybit_raw, "settle_coin", "USDT").upper(),
|
||||
)
|
||||
else:
|
||||
binance_raw = _section(raw, "binance")
|
||||
_reject_binance_endpoint_overrides(binance_raw)
|
||||
spot_raw = _section(binance_raw, "spot")
|
||||
usdm_raw = _section(binance_raw, "usdm")
|
||||
_reject_binance_endpoint_overrides(spot_raw)
|
||||
_reject_binance_endpoint_overrides(usdm_raw)
|
||||
binance = BinanceConfig(
|
||||
spot=BinanceAccountConfig(_secret(spot_raw, "api_key"), _secret(spot_raw, "api_secret")),
|
||||
usdm=BinanceAccountConfig(_secret(usdm_raw, "api_key"), _secret(usdm_raw, "api_secret")),
|
||||
)
|
||||
|
||||
admin_ids = telegram_raw.get("admin_user_ids")
|
||||
if not isinstance(admin_ids, list) or not admin_ids or any(not isinstance(item, int) for item in admin_ids):
|
||||
@@ -183,7 +228,19 @@ def load_config(path: str | Path) -> AppConfig:
|
||||
max_notes=_integer(storage_raw, "max_notes", 50, minimum=1, maximum=1000),
|
||||
max_notes_chars=_integer(storage_raw, "max_notes_chars", 20_000, minimum=100, maximum=200_000),
|
||||
)
|
||||
return AppConfig(deepseek, bybit, telegram, brave, prompts, schedule, scanner, risk, storage)
|
||||
return AppConfig(deepseek, bybit, telegram, brave, prompts, schedule, scanner, risk, storage, exchange, binance)
|
||||
|
||||
|
||||
def _reject_binance_endpoint_overrides(section: dict[str, Any]) -> None:
|
||||
forbidden = {
|
||||
key
|
||||
for key in section
|
||||
if key.lower() in {"base_url", "rest_url", "ws_url", "websocket_url", "endpoint", "testnet"}
|
||||
or key.lower().endswith("_url")
|
||||
}
|
||||
if forbidden:
|
||||
names = ", ".join(sorted(forbidden))
|
||||
raise ConfigError(f"Binance endpoints are fixed to Testnet and cannot be configured: {names}")
|
||||
|
||||
|
||||
def _section(raw: dict[str, Any], name: str) -> dict[str, Any]:
|
||||
|
||||
@@ -93,12 +93,15 @@ class Database:
|
||||
CREATE TABLE IF NOT EXISTS managed_orders (
|
||||
order_link_id TEXT PRIMARY KEY,
|
||||
order_id TEXT,
|
||||
provider TEXT NOT NULL DEFAULT 'bybit',
|
||||
category TEXT NOT NULL,
|
||||
symbol TEXT NOT NULL,
|
||||
side TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
expires_at TEXT,
|
||||
trailing_distance TEXT,
|
||||
entry_order_id TEXT,
|
||||
protection_order_ids_json TEXT NOT NULL DEFAULT '[]',
|
||||
raw_json TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
@@ -109,6 +112,18 @@ class Database:
|
||||
);
|
||||
"""
|
||||
)
|
||||
cursor = await self.connection.execute("PRAGMA table_info(managed_orders)")
|
||||
columns = {str(row["name"]) for row in await cursor.fetchall()}
|
||||
if "provider" not in columns:
|
||||
await self.connection.execute(
|
||||
"ALTER TABLE managed_orders ADD COLUMN provider TEXT NOT NULL DEFAULT 'bybit'"
|
||||
)
|
||||
if "entry_order_id" not in columns:
|
||||
await self.connection.execute("ALTER TABLE managed_orders ADD COLUMN entry_order_id TEXT")
|
||||
if "protection_order_ids_json" not in columns:
|
||||
await self.connection.execute(
|
||||
"ALTER TABLE managed_orders ADD COLUMN protection_order_ids_json TEXT NOT NULL DEFAULT '[]'"
|
||||
)
|
||||
await self.connection.commit()
|
||||
|
||||
async def add_note(self, text: str) -> int:
|
||||
@@ -244,52 +259,74 @@ class Database:
|
||||
expires_at: datetime | None,
|
||||
trailing_distance: str | None,
|
||||
raw: Any,
|
||||
provider: str = "bybit",
|
||||
entry_order_id: str | None = None,
|
||||
protection_order_ids: list[str] | None = None,
|
||||
) -> None:
|
||||
raw_json = json.dumps(raw, ensure_ascii=False, default=str, separators=(",", ":"))
|
||||
protection_json = json.dumps(protection_order_ids or [], separators=(",", ":"))
|
||||
await self.connection.execute(
|
||||
"""
|
||||
INSERT INTO managed_orders(
|
||||
order_link_id, order_id, category, symbol, side, status,
|
||||
expires_at, trailing_distance, raw_json, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
order_link_id, order_id, provider, category, symbol, side, status,
|
||||
expires_at, trailing_distance, entry_order_id, protection_order_ids_json,
|
||||
raw_json, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(order_link_id) DO UPDATE SET
|
||||
order_id=excluded.order_id, status=excluded.status,
|
||||
order_id=excluded.order_id, provider=excluded.provider, status=excluded.status,
|
||||
expires_at=excluded.expires_at, trailing_distance=excluded.trailing_distance,
|
||||
entry_order_id=excluded.entry_order_id,
|
||||
protection_order_ids_json=excluded.protection_order_ids_json,
|
||||
raw_json=excluded.raw_json, updated_at=excluded.updated_at
|
||||
""",
|
||||
(
|
||||
order_link_id,
|
||||
order_id,
|
||||
provider,
|
||||
category,
|
||||
symbol,
|
||||
side,
|
||||
status,
|
||||
expires_at.isoformat() if expires_at else None,
|
||||
trailing_distance,
|
||||
entry_order_id,
|
||||
protection_json,
|
||||
raw_json,
|
||||
utc_now().isoformat(),
|
||||
),
|
||||
)
|
||||
await self.connection.commit()
|
||||
|
||||
async def expired_managed_orders(self, now: datetime | None = None) -> list[dict[str, str]]:
|
||||
async def expired_managed_orders(
|
||||
self, now: datetime | None = None, *, provider: str | None = None
|
||||
) -> list[dict[str, str]]:
|
||||
provider_clause = " AND provider=?" if provider else ""
|
||||
parameters: list[str] = [(now or utc_now()).isoformat()]
|
||||
if provider:
|
||||
parameters.append(provider)
|
||||
cursor = await self.connection.execute(
|
||||
"""
|
||||
SELECT order_link_id, order_id, category, symbol FROM managed_orders
|
||||
f"""
|
||||
SELECT order_link_id, order_id, provider, category, symbol FROM managed_orders
|
||||
WHERE status IN ('New', 'Created', 'PartiallyFilled') AND expires_at IS NOT NULL AND expires_at<=?
|
||||
{provider_clause}
|
||||
""",
|
||||
((now or utc_now()).isoformat(),),
|
||||
parameters,
|
||||
)
|
||||
return [dict(row) for row in await cursor.fetchall()]
|
||||
|
||||
async def managed_orders_with_pending_trailing(self) -> list[dict[str, str | None]]:
|
||||
async def managed_orders_with_pending_trailing(
|
||||
self, *, provider: str | None = None
|
||||
) -> list[dict[str, str | None]]:
|
||||
provider_clause = " AND provider=?" if provider else ""
|
||||
cursor = await self.connection.execute(
|
||||
"""
|
||||
SELECT order_link_id, order_id, category, symbol, trailing_distance
|
||||
f"""
|
||||
SELECT order_link_id, order_id, provider, category, symbol, trailing_distance
|
||||
FROM managed_orders
|
||||
WHERE category='linear' AND trailing_distance IS NOT NULL
|
||||
AND status IN ('Created', 'New', 'PartiallyFilled')
|
||||
"""
|
||||
{provider_clause}
|
||||
""",
|
||||
(provider,) if provider else (),
|
||||
)
|
||||
return [dict(row) for row in await cursor.fetchall()]
|
||||
|
||||
|
||||
16
src/llm_trade_bot/exchange_factory.py
Normal file
16
src/llm_trade_bot/exchange_factory.py
Normal 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)
|
||||
116
src/llm_trade_bot/exchange_gateway.py
Normal file
116
src/llm_trade_bot/exchange_gateway.py
Normal 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
|
||||
@@ -3,16 +3,13 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from contextlib import suppress
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from pybit.unified_trading import WebSocket
|
||||
|
||||
from llm_trade_bot.agent import AgentRunner
|
||||
from llm_trade_bot.bybit_gateway import BybitGateway
|
||||
from llm_trade_bot.config import AppConfig
|
||||
from llm_trade_bot.database import Database
|
||||
from llm_trade_bot.exchange_factory import create_exchange_gateway
|
||||
from llm_trade_bot.scheduler import next_scheduled_time
|
||||
from llm_trade_bot.telegram_bot import TelegramBot
|
||||
|
||||
@@ -23,27 +20,27 @@ class TradeBotService:
|
||||
def __init__(self, config: AppConfig):
|
||||
self.config = config
|
||||
self.database = Database(config.storage.database_path)
|
||||
self.bybit = BybitGateway(config.bybit, config.scanner)
|
||||
self.agent = AgentRunner(config, self.database, self.bybit)
|
||||
self.exchange = create_exchange_gateway(config)
|
||||
self.bybit = self.exchange # compatibility with Telegram extensions
|
||||
self.agent = AgentRunner(config, self.database, self.exchange)
|
||||
self.telegram = TelegramBot(config.telegram, self)
|
||||
self._run_lock = asyncio.Lock()
|
||||
self._paused = False
|
||||
self._tasks: set[asyncio.Task[Any]] = set()
|
||||
self._stopping = asyncio.Event()
|
||||
self._websocket: WebSocket | None = None
|
||||
self._loop: asyncio.AbstractEventLoop | None = None
|
||||
|
||||
async def start(self) -> None:
|
||||
await self.database.open()
|
||||
self._paused = (await self.database.get_state("paused", "false")) == "true"
|
||||
await self.bybit.startup_check()
|
||||
await self.exchange.startup_check()
|
||||
await self.telegram.start()
|
||||
self._loop = asyncio.get_running_loop()
|
||||
self._start_private_websocket()
|
||||
self._spawn(self.exchange.private_stream(self._record_exchange_event, self._stopping), "private-stream")
|
||||
self._spawn(self._scheduler_loop(), "scheduler")
|
||||
self._spawn(self._outbox_loop(), "outbox")
|
||||
self._spawn(self._reconcile_loop(), "reconciler")
|
||||
await self.database.queue_message("🤖 LLM Trade Bot запущен в режиме Bybit Demo.")
|
||||
await self.database.queue_message(
|
||||
f"🤖 LLM Trade Bot запущен: {self.exchange.provider} {self.exchange.environment}."
|
||||
)
|
||||
|
||||
async def stop(self) -> None:
|
||||
self._stopping.set()
|
||||
@@ -51,11 +48,14 @@ class TradeBotService:
|
||||
task.cancel()
|
||||
if self._tasks:
|
||||
await asyncio.gather(*self._tasks, return_exceptions=True)
|
||||
if self._websocket is not None:
|
||||
with suppress(Exception):
|
||||
self._websocket.exit()
|
||||
with suppress(Exception):
|
||||
try:
|
||||
await self.telegram.stop()
|
||||
except Exception:
|
||||
LOGGER.exception("Telegram shutdown failed")
|
||||
try:
|
||||
await self.exchange.close()
|
||||
except Exception:
|
||||
LOGGER.exception("Exchange gateway shutdown failed")
|
||||
await self.database.close()
|
||||
|
||||
async def wait(self) -> None:
|
||||
@@ -95,7 +95,7 @@ class TradeBotService:
|
||||
async def pause(self) -> str:
|
||||
self._paused = True
|
||||
await self.database.set_state("paused", "true")
|
||||
return "Новые анализы и входы приостановлены. Защитные ордера Bybit остаются активны."
|
||||
return f"Новые анализы и входы приостановлены. Защитные ордера {self.exchange.provider} остаются активны."
|
||||
|
||||
async def resume(self) -> str:
|
||||
self._paused = False
|
||||
@@ -106,7 +106,8 @@ class TradeBotService:
|
||||
next_run = next_scheduled_time(self.config.schedule)
|
||||
last = await self.database.last_run()
|
||||
return (
|
||||
f"Режим: Bybit Demo\n"
|
||||
f"Биржа: {self.exchange.provider}\n"
|
||||
f"Среда: {self.exchange.environment}\n"
|
||||
f"Состояние: {'пауза' if self._paused else 'активен'}\n"
|
||||
f"Агент: {'работает' if self._run_lock.locked() else 'ожидает'}\n"
|
||||
f"Следующий запуск: {next_run.isoformat()}\n"
|
||||
@@ -114,13 +115,13 @@ class TradeBotService:
|
||||
)
|
||||
|
||||
async def positions_text(self) -> str:
|
||||
state = await self.bybit.account_state()
|
||||
state = await self.exchange.account_state()
|
||||
return "Позиции и активы:\n" + json.dumps(
|
||||
{"wallet": state["wallet"], "positions": state["positions"]}, ensure_ascii=False, default=str
|
||||
{"wallet": state.get("wallet", state.get("wallets")), "positions": state["positions"]}, ensure_ascii=False, default=str
|
||||
)[:3800]
|
||||
|
||||
async def orders_text(self) -> str:
|
||||
orders = await self.bybit.open_orders()
|
||||
orders = await self.exchange.open_orders()
|
||||
return "Открытые ордера:\n" + json.dumps(orders, ensure_ascii=False, default=str)[:3800]
|
||||
|
||||
async def last_run_text(self) -> str:
|
||||
@@ -164,19 +165,25 @@ class TradeBotService:
|
||||
while not self._stopping.is_set():
|
||||
try:
|
||||
await self._cancel_expired_orders()
|
||||
await self._apply_pending_trailing_stops()
|
||||
if self.exchange.provider == "bybit":
|
||||
await self._apply_pending_trailing_stops()
|
||||
for event in await self.exchange.reconcile():
|
||||
await self._record_exchange_event(event)
|
||||
except Exception:
|
||||
LOGGER.exception("Bybit reconciliation failed")
|
||||
LOGGER.exception("%s reconciliation failed", self.exchange.provider)
|
||||
try:
|
||||
await asyncio.wait_for(self._stopping.wait(), timeout=60)
|
||||
except TimeoutError:
|
||||
pass
|
||||
|
||||
async def _cancel_expired_orders(self) -> None:
|
||||
for order in await self.database.expired_managed_orders():
|
||||
for order in await self.database.expired_managed_orders(provider=self.exchange.provider):
|
||||
try:
|
||||
await self.bybit.cancel_order(
|
||||
category=order["category"], symbol=order["symbol"], orderId=order["order_id"]
|
||||
await self.exchange.cancel_order(
|
||||
order["category"],
|
||||
order["symbol"],
|
||||
order_id=order["order_id"],
|
||||
client_id=order["order_link_id"] if self.exchange.provider == "binance" else None,
|
||||
)
|
||||
await self.database.mark_managed_order_status(order["order_link_id"], "Cancelled")
|
||||
await self.database.queue_message(
|
||||
@@ -187,8 +194,8 @@ class TradeBotService:
|
||||
raise
|
||||
|
||||
async def _apply_pending_trailing_stops(self) -> None:
|
||||
for managed in await self.database.managed_orders_with_pending_trailing():
|
||||
history = await self.bybit.order_history("linear", 50)
|
||||
for managed in await self.database.managed_orders_with_pending_trailing(provider="bybit"):
|
||||
history = await self.exchange.order_history("linear", 50)
|
||||
matching = next(
|
||||
(item for item in history if item.get("orderLinkId") == managed["order_link_id"]), None
|
||||
)
|
||||
@@ -199,7 +206,7 @@ class TradeBotService:
|
||||
trailing_distance = managed["trailing_distance"]
|
||||
if order_link_id is None or symbol is None or trailing_distance is None:
|
||||
continue
|
||||
await self.bybit.set_trading_stop(
|
||||
await self.exchange.set_trading_stop( # type: ignore[attr-defined]
|
||||
category="linear",
|
||||
symbol=symbol,
|
||||
positionIdx=0,
|
||||
@@ -211,38 +218,25 @@ class TradeBotService:
|
||||
f"🛡 Trailing-stop установлен: {symbol} distance={trailing_distance}"
|
||||
)
|
||||
|
||||
def _start_private_websocket(self) -> None:
|
||||
websocket = WebSocket(
|
||||
testnet=False,
|
||||
demo=True,
|
||||
channel_type="private",
|
||||
api_key=self.config.bybit.api_key,
|
||||
api_secret=self.config.bybit.api_secret,
|
||||
)
|
||||
websocket.order_stream(callback=self._websocket_callback("order"))
|
||||
websocket.execution_stream(callback=self._websocket_callback("execution"))
|
||||
websocket.position_stream(callback=self._websocket_callback("position"))
|
||||
websocket.wallet_stream(callback=self._websocket_callback("wallet"))
|
||||
self._websocket = websocket
|
||||
|
||||
def _websocket_callback(self, kind: str):
|
||||
def callback(message: dict[str, Any]) -> None:
|
||||
if self._loop is None or self._loop.is_closed():
|
||||
return
|
||||
future = asyncio.run_coroutine_threadsafe(self._record_ws_event(kind, message), self._loop)
|
||||
future.add_done_callback(lambda completed: completed.exception() if not completed.cancelled() else None)
|
||||
|
||||
return callback
|
||||
|
||||
async def _record_ws_event(self, kind: str, message: dict[str, Any]) -> None:
|
||||
await self.database.add_event(f"bybit_ws_{kind}", message)
|
||||
if kind == "order":
|
||||
for order in message.get("data", []):
|
||||
async def _record_exchange_event(self, message: dict[str, Any]) -> None:
|
||||
kind = str(message.get("kind") or "event")
|
||||
await self.database.add_event(f"{self.exchange.provider}_ws_{kind}", message)
|
||||
if kind == "reconcile_required":
|
||||
for event in await self.exchange.reconcile():
|
||||
await self.database.add_event(f"{self.exchange.provider}_reconcile", event)
|
||||
return
|
||||
client_id = message.get("client_id")
|
||||
status = message.get("status")
|
||||
if isinstance(client_id, str) and client_id.startswith("ltb-") and status:
|
||||
await self.database.mark_managed_order_status(client_id, str(status))
|
||||
if self.exchange.provider == "bybit" and kind == "order":
|
||||
raw = message.get("raw", {})
|
||||
for order in raw.get("data", []) if isinstance(raw, dict) else []:
|
||||
order_link_id = order.get("orderLinkId")
|
||||
order_status = order.get("orderStatus")
|
||||
if order_link_id and order_link_id.startswith("ltb-") and order_status:
|
||||
await self.database.mark_managed_order_status(order_link_id, order_status)
|
||||
if kind in {"order", "execution"}:
|
||||
await self.database.queue_message(
|
||||
f"Bybit {kind}: {json.dumps(message.get('data', message), ensure_ascii=False, default=str)[:3000]}"
|
||||
f"{self.exchange.provider} {kind}: {json.dumps(message, ensure_ascii=False, default=str)[:3000]}"
|
||||
)
|
||||
|
||||
109
tests/test_binance_client.py
Normal file
109
tests/test_binance_client.py
Normal 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×tamp=1645423376532"
|
||||
assert sign_parameters(parameters, "secret") == hmac.new(
|
||||
b"secret", payload.encode(), hashlib.sha256
|
||||
).hexdigest()
|
||||
|
||||
|
||||
async def test_signed_request_resynchronizes_time_and_resigns():
|
||||
seen: list[httpx.Request] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen.append(request)
|
||||
if request.url.path == "/api/v3/time":
|
||||
return httpx.Response(200, json={"serverTime": 1_000_100}, request=request)
|
||||
if len([item for item in seen if item.url.path == "/api/v3/account"]) == 1:
|
||||
return httpx.Response(400, json={"code": -1021, "msg": "outside recvWindow"}, request=request)
|
||||
query = dict(request.url.params)
|
||||
unsigned = {key: value for key, value in query.items() if key != "signature"}
|
||||
assert query["signature"] == sign_parameters(unsigned, "secret")
|
||||
assert query["recvWindow"] == "5000"
|
||||
return httpx.Response(200, json={"ok": True}, request=request)
|
||||
|
||||
client = BinanceRestClient(
|
||||
SPOT_REST_URL,
|
||||
BinanceCredentials("key", "secret"),
|
||||
http_client=httpx.AsyncClient(base_url=SPOT_REST_URL, transport=httpx.MockTransport(handler)),
|
||||
clock=lambda: 1000.0,
|
||||
)
|
||||
assert await client.request("GET", "/api/v3/account", signed=True) == {"ok": True}
|
||||
assert [request.url.path for request in seen] == ["/api/v3/account", "/api/v3/time", "/api/v3/account"]
|
||||
|
||||
|
||||
async def test_rate_limit_honors_retry_after():
|
||||
attempts = 0
|
||||
pauses: list[float] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
nonlocal attempts
|
||||
attempts += 1
|
||||
if attempts == 1:
|
||||
return httpx.Response(429, headers={"Retry-After": "0.25"}, json={"code": -1003}, request=request)
|
||||
return httpx.Response(200, json={"value": "ok"}, request=request)
|
||||
|
||||
async def sleep(delay: float) -> None:
|
||||
pauses.append(delay)
|
||||
|
||||
client = BinanceRestClient(
|
||||
SPOT_REST_URL,
|
||||
BinanceCredentials("key", "secret"),
|
||||
http_client=httpx.AsyncClient(base_url=SPOT_REST_URL, transport=httpx.MockTransport(handler)),
|
||||
sleep=sleep,
|
||||
)
|
||||
assert await client.request("GET", "/api/v3/ping") == {"value": "ok"}
|
||||
assert pauses == [0.25]
|
||||
|
||||
|
||||
async def test_ambiguous_mutation_queries_client_id_instead_of_retrying_post():
|
||||
methods: list[str] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
methods.append(request.method)
|
||||
if request.method == "POST":
|
||||
return httpx.Response(500, json={"msg": "backend timeout"}, request=request)
|
||||
return httpx.Response(200, json={"orderId": 42, "clientOrderId": "ltb-1"}, request=request)
|
||||
|
||||
client = BinanceRestClient(
|
||||
SPOT_REST_URL,
|
||||
BinanceCredentials("key", "secret"),
|
||||
http_client=httpx.AsyncClient(base_url=SPOT_REST_URL, transport=httpx.MockTransport(handler)),
|
||||
)
|
||||
result = await client.mutate_with_recovery(
|
||||
"POST",
|
||||
"/api/v3/order",
|
||||
{"symbol": "BTCUSDT", "newClientOrderId": "ltb-1"},
|
||||
lookup_path="/api/v3/order",
|
||||
lookup_parameters={"symbol": "BTCUSDT", "origClientOrderId": "ltb-1"},
|
||||
)
|
||||
assert result["orderId"] == 42
|
||||
assert methods == ["POST", "GET"]
|
||||
|
||||
|
||||
def test_production_url_is_not_accepted():
|
||||
with pytest.raises(ValueError, match="Testnet"):
|
||||
BinanceRestClient("https://api.binance.com", BinanceCredentials("key", "secret"))
|
||||
162
tests/test_binance_gateway.py
Normal file
162
tests/test_binance_gateway.py
Normal 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
|
||||
@@ -75,3 +75,66 @@ def test_rejects_missing_prompts_file(tmp_path: Path):
|
||||
path.write_text(CONFIG, encoding="utf-8")
|
||||
with pytest.raises(ConfigError, match="Prompts file not found"):
|
||||
load_config(path)
|
||||
|
||||
|
||||
def test_legacy_bybit_config_selects_bybit(tmp_path: Path):
|
||||
config = load_config(write_config(tmp_path))
|
||||
assert config.exchange.provider == "bybit"
|
||||
assert config.bybit is not None
|
||||
assert config.binance is None
|
||||
|
||||
|
||||
def test_binance_config_only_requires_binance_credentials(tmp_path: Path):
|
||||
content = CONFIG.replace(
|
||||
'[bybit]\napi_key = "bybit-key"\napi_secret = "bybit-secret"\n',
|
||||
'''[exchange]
|
||||
provider = "binance"
|
||||
[binance.spot]
|
||||
api_key = "spot-key"
|
||||
api_secret = "spot-secret"
|
||||
[binance.usdm]
|
||||
api_key = "usdm-key"
|
||||
api_secret = "usdm-secret"
|
||||
''',
|
||||
)
|
||||
config = load_config(write_config(tmp_path, content))
|
||||
assert config.exchange.provider == "binance"
|
||||
assert config.bybit is None
|
||||
assert config.binance is not None
|
||||
assert config.binance.spot.api_key == "spot-key"
|
||||
|
||||
|
||||
def test_binance_rejects_endpoint_override(tmp_path: Path):
|
||||
content = CONFIG.replace(
|
||||
'[bybit]\napi_key = "bybit-key"\napi_secret = "bybit-secret"\n',
|
||||
'''[exchange]
|
||||
provider = "binance"
|
||||
[binance]
|
||||
base_url = "https://api.binance.com"
|
||||
[binance.spot]
|
||||
api_key = "spot-key"
|
||||
api_secret = "spot-secret"
|
||||
[binance.usdm]
|
||||
api_key = "usdm-key"
|
||||
api_secret = "usdm-secret"
|
||||
''',
|
||||
)
|
||||
with pytest.raises(ConfigError, match="fixed to Testnet"):
|
||||
load_config(write_config(tmp_path, content))
|
||||
|
||||
|
||||
def test_binance_requires_both_selected_wallet_credentials(tmp_path: Path):
|
||||
content = CONFIG.replace(
|
||||
'[bybit]\napi_key = "bybit-key"\napi_secret = "bybit-secret"\n',
|
||||
'''[exchange]
|
||||
provider = "binance"
|
||||
[binance.spot]
|
||||
api_key = "spot-key"
|
||||
api_secret = "spot-secret"
|
||||
[binance.usdm]
|
||||
api_key = "replace-me"
|
||||
api_secret = "replace-me"
|
||||
''',
|
||||
)
|
||||
with pytest.raises(ConfigError, match="placeholder"):
|
||||
load_config(write_config(tmp_path, content))
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
from llm_trade_bot.database import Database
|
||||
@@ -35,3 +36,44 @@ async def test_run_lifecycle(tmp_path: Path):
|
||||
assert last["summary"] == "idle"
|
||||
finally:
|
||||
await database.close()
|
||||
|
||||
|
||||
async def test_managed_orders_migrate_legacy_rows_to_bybit(tmp_path: Path):
|
||||
path = tmp_path / "legacy.db"
|
||||
connection = sqlite3.connect(path)
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE managed_orders (
|
||||
order_link_id TEXT PRIMARY KEY, order_id TEXT, category TEXT NOT NULL,
|
||||
symbol TEXT NOT NULL, side TEXT NOT NULL, status TEXT NOT NULL,
|
||||
expires_at TEXT, trailing_distance TEXT, raw_json TEXT NOT NULL, updated_at TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO managed_orders VALUES (
|
||||
'ltb-old', 'old-1', 'linear', 'BTCUSDT', 'Buy', 'Created',
|
||||
'2000-01-01T00:00:00+00:00', NULL, '{}', '2000-01-01T00:00:00+00:00'
|
||||
)
|
||||
"""
|
||||
)
|
||||
connection.commit()
|
||||
connection.close()
|
||||
|
||||
database = Database(path)
|
||||
await database.open()
|
||||
try:
|
||||
rows = await database.expired_managed_orders(provider="bybit")
|
||||
assert rows == [
|
||||
{
|
||||
"order_link_id": "ltb-old",
|
||||
"order_id": "old-1",
|
||||
"provider": "bybit",
|
||||
"category": "linear",
|
||||
"symbol": "BTCUSDT",
|
||||
}
|
||||
]
|
||||
assert await database.expired_managed_orders(provider="binance") == []
|
||||
finally:
|
||||
await database.close()
|
||||
|
||||
79
uv.lock
generated
79
uv.lock
generated
@@ -411,6 +411,7 @@ dependencies = [
|
||||
{ name = "pybit" },
|
||||
{ name = "tgcrypto" },
|
||||
{ name = "tzdata" },
|
||||
{ name = "websockets" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
@@ -430,6 +431,7 @@ requires-dist = [
|
||||
{ name = "pybit", specifier = ">=5.12,<6" },
|
||||
{ name = "tgcrypto", specifier = ">=1.2.5,<2" },
|
||||
{ name = "tzdata", specifier = ">=2026.1" },
|
||||
{ name = "websockets", specifier = ">=15,<17" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
@@ -831,3 +833,80 @@ sdist = { url = "https://files.pythonhosted.org/packages/d8/cb/a5abcc2891249f393
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/d2/cc4dc1271e464942db7ee278baae2daa99ee77cb2af744025c04da585a3e/websocket_client-1.9.2-py3-none-any.whl", hash = "sha256:e1a673830a9c7bfa47b1cd3d5e4178f4c9651d80a4eab02c9c23a1c3ec6250ce", size = 95786, upload-time = "2026-08-31T14:08:39.899Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "websockets"
|
||||
version = "16.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/21/f7/bc3a25c5ec26ce62ce487690becc2f3710bbc7b33338f005ad390db0b986/websockets-16.1.1.tar.gz", hash = "sha256:db234eda965dcce15df96bb9709f587cd87d4d52aaf0e80e2f34ec04c7670c57", size = 182204, upload-time = "2026-07-17T22:51:05.858Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/17/9d/681cda21c9eee743203a6cb79b9d3d05adad9aa60ec660c6c9bf4dd619ca/websockets-16.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:cc97814dfb786a83b6e2dc2e79351e1b83e6d715647d6887fcabd83026417a00", size = 179600, upload-time = "2026-07-17T22:49:13.92Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/8d/6195a88b45e8d2a8f745fc2046e36f885a3c9763e6767d2c46229bf9510c/websockets-16.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e047dc87ef7ca50f4d309bf775ad4a71711c58556d75d7bd0604b2317f43e94b", size = 177272, upload-time = "2026-07-17T22:49:15.453Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/e3/fe2d498c64dea0095c9a9f9a351af4cd6eef31b618395582bc1f38ba45ff/websockets-16.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:01fbdcbac298efe19360b94bc0039c8f746f0220ba570f327577bfee81059175", size = 177542, upload-time = "2026-07-17T22:49:16.875Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/ed/f1831681fce0e3242346e5458486003c5f124ed69e5e0b847fd029db4973/websockets-16.1.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0f62863e8a00a6d33c3d6566ec0b89f23787b747ffe0c3bc71ec0e76b82c94b1", size = 187137, upload-time = "2026-07-17T22:49:18.323Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/79/4ff9dcc1bb46f6b4c536936dde1fd60f9b564f3304307274db97f4c9496d/websockets-16.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8087e82f842609734c9b5a1330464f8e94e346ba0e18c832c08bafa4b0d63c15", size = 188374, upload-time = "2026-07-17T22:49:19.65Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/c3/5c49b6efb36cab733d23773f6de575e1dba65736ead17d5d2b2a1daef779/websockets-16.1.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2bb5d041a8307d2e18782e7ce777f6fdb1e8c2f5d09291484b18c294b789d9aa", size = 191155, upload-time = "2026-07-17T22:49:21.331Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/f6/56ccceda3a4838d18f1d40821480da4775397e8b1eecf4031e20c50e2e90/websockets-16.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1db4de4a0e95673f7545d393c49eeb0c2f18ac1ef93073218c79d5cdb2ee75ab", size = 189011, upload-time = "2026-07-17T22:49:22.889Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/d6/ad5286241a2bce1107e2798d3bfbd62cf79aee167bdb654f8cb1e9dbf949/websockets-16.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f17dbe07eb3ea7f99e4df9b7e0efefe80fbf30d37a8cc4d561a0aed310bc8847", size = 187766, upload-time = "2026-07-17T22:49:24.339Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/67/d65c970b7e347fdca69479beb7811c2060529956730a7a4e3ae7c66b0e31/websockets-16.1.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4b57693728576d84ede0a77987ab16881b783d2cd9f1dc180a8fbbc3f79c4428", size = 185173, upload-time = "2026-07-17T22:49:25.743Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/5b/14af3cd4ee69d8ea9baca58f3dc3cfb1ba78332a347fd478cb096549d60e/websockets-16.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2a636ff1e7a5c4edf71ef0e79adae7f25dba93b4fcbe3dc958733477ffeb0eaf", size = 187809, upload-time = "2026-07-17T22:49:27.147Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/11/be301710d70de97e3e7b3586e6d492c9c06d6a61bf1c2202c36cf0c75607/websockets-16.1.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d6bec75c290fe484a8ba4cacdf838501e17c06ecfbbf31eede81a9e431bd7751", size = 186412, upload-time = "2026-07-17T22:49:28.611Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/07/fe1435bf6fe738a3d3b54dbe0c18dabf12cba4d909ac8b58b539ce27c1f4/websockets-16.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:54509b8e92fee4453e152b7558ddef37ce9705a044922f2095a6105e3f80c96f", size = 188290, upload-time = "2026-07-17T22:49:29.965Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/0a/81f394aff8efcbb01208c1ced77df0a3c7fcce584a88c7273663697946c2/websockets-16.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:f0aa4aad3b1b69ad3fd85a0fd0952ec64331c762bd77ec51cc814170873890b2", size = 185844, upload-time = "2026-07-17T22:49:31.447Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/5c/dd485b995473f415510251fe9bd708f2d24458f439fce958daf8d66dc7c6/websockets-16.1.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:42290eb6db4ccaca7012656738214f8514082fb6fa40cdeb61bb9a471b52e383", size = 186823, upload-time = "2026-07-17T22:49:33.104Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/0b/f78de76ff446f1e66af12b43c48a35f31744de93cfdec2f4ea67d5d7bbf1/websockets-16.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:53260c8930da5771cec89439bff99c20c8cb03ddb9588b980697355a83cd4bd3", size = 187102, upload-time = "2026-07-17T22:49:34.616Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/a1/4cf892007778eaf84ad162bfc98046e0ed89b63ac55949e3236626b2a23f/websockets-16.1.1-cp312-cp312-win32.whl", hash = "sha256:1d27fa8462ad6a1cb36206a3d0640b2333340def181fae11ed7f9adeaa5c0747", size = 179943, upload-time = "2026-07-17T22:49:36.213Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/de/6abe251d28c3a3f217096575400b27750b18e0b1d2fff3a2a239960fea07/websockets-16.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:b436f6ec4fc3a6b4237c84d3f83170ed2b40bb584222f0ac47a0c8a5921980c7", size = 180243, upload-time = "2026-07-17T22:49:37.626Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/fd/6ec6c6d2850aea25b1b2aa9901a016980bb87d01e89b3eb00470b1b5d471/websockets-16.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ab59169ace05dcb49a1d4118f0bde139557adf45091bd85747e36bf5de984dd1", size = 179587, upload-time = "2026-07-17T22:49:38.959Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/d8/1d299d2dd34087db39831a34cc645ef8a6f89d78efada6983093513cd81c/websockets-16.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5e3b7d601f6f84156b08cc4a5e541c2b50ad7b36cfc302b657a12477c904a5df", size = 177272, upload-time = "2026-07-17T22:49:40.293Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/86/0a70d3ae2f0f2256bb41302d9804dbca65d4360281e7feb3e1f94102ac46/websockets-16.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cd2ca96a082a36964aca83e992f72abeb61b7306c1a6cba4c7d06a7b93750cac", size = 177530, upload-time = "2026-07-17T22:49:41.786Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/c2/c676c69444d9db448b3f0a55a98dcc534affce0bce961d9d2f0b8499b10a/websockets-16.1.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f5d497865f05bb222cab7016c6034542e84e5f29f49c6fd3f4939cda7197b5b8", size = 187197, upload-time = "2026-07-17T22:49:43.658Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/13/88137fbaf726ebe29d62c1117fa11fa2bbb6209dc79d4ad738efbe36a2aa/websockets-16.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bae954c382e013d5ea5b190d2830526bfa45ad121c326da0049b8c769f185db6", size = 188433, upload-time = "2026-07-17T22:49:45.147Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/6d/46c2f2ce6751cb26f39293e1ecbf8544cb01321397cd476c2756b98c216d/websockets-16.1.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e09f753a169951eb4f28c2c774f71069304f66e7277e0f5a2892423599cfa854", size = 189868, upload-time = "2026-07-17T22:49:46.581Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/2b/170a9e8097636cfde4dc3c592b6e00b18a44a2f5407606d96ca542dd5838/websockets-16.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:024193f8551a2b0eafbdd160911012c4e6c228c28430c84433253299a9e42d6a", size = 189059, upload-time = "2026-07-17T22:49:47.972Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/48/f0d4ebc9ab4b473b8861b9e20fdb663d515d42f7befdf62cdb60fee7a1ec/websockets-16.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:aabe464bfd13bd25f4821faf111da6fefdc389f870265a53105580e45b0a2e49", size = 187814, upload-time = "2026-07-17T22:49:49.344Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/ba/39a41d3ae8e72696a9492581900611c5a91e2b07563b0bcd2523adea9854/websockets-16.1.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a28fcbc9b6baf54a2e23f8655f308e4ccc6afdd7266f8fe7954f320dcda0f785", size = 185229, upload-time = "2026-07-17T22:49:50.787Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/36/ac15b604f850d1907f0a85ed721cefe47cd45034b3620069b829746cccbe/websockets-16.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:79eace538c6a97e96d0d03d4f9d314f9677f5ed85a8a984992ffd90b13cb8a56", size = 187874, upload-time = "2026-07-17T22:49:52.228Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/f3/3fbd5d71d59299c3770faa5884d4f45070236ca5a35ab3a61830812c409a/websockets-16.1.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:496af849a472b531f758dbd4d61338f5000538cb1a7b3d20d9d32a264517f509", size = 186469, upload-time = "2026-07-17T22:49:53.776Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/fc/dd90349bba58af2a53ef2ddd9c32716c81eb6d59a0687939fff561860878/websockets-16.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5283810d2646741a0d8da2aa733d6aefa0545809afccb2a5d105a26bc45125f1", size = 188347, upload-time = "2026-07-17T22:49:55.202Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/f3/f73ba86427682da59b78c11d77ba56d5b801c32e84afe79b274bbd6a9bb2/websockets-16.1.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4e3b680b1e0a27457e727a0d572fd81dffa87b6dbf8b228ab57da64f7d85aead", size = 185903, upload-time = "2026-07-17T22:49:56.75Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/7c/f95eb20e80104173b3a0a092291f89ea4047ef6e608e0a57ca06eb14eecb/websockets-16.1.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:69159730a823dde3ea8d08783e8d47ef135a6d7e8d44eb127e32b321c9db8e3e", size = 186855, upload-time = "2026-07-17T22:49:58.467Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/35/dd875b3e050ff232d60fa377707f890e369f74d134f1be32e8f68879747c/websockets-16.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ed5bb271084b46530ee2ddc0410537a9961152c5ccba2fc98c5276d992ccba87", size = 187140, upload-time = "2026-07-17T22:50:00.016Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/dc/5cbfcb41824502f6af93b8f3943a4d06c67c23c7d2e31eb18748c4a5b2a7/websockets-16.1.1-cp313-cp313-win32.whl", hash = "sha256:cfb70b4eb56cac4da0a83588f3ad50d46beb0690391082f3d4e2d488c70b68ea", size = 179928, upload-time = "2026-07-17T22:50:01.685Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/c1/71e5deb5b7f8f226997ab64908c184ac3105c0155ce2d486f318e5dd08a8/websockets-16.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:d9531d9cbeac99af6f038fb1bc351403531f7d634a2c2e10e2f7c854c6ed5b68", size = 180242, upload-time = "2026-07-17T22:50:03.117Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/a2/ba78a164eeea4620df4a4df4bd2ed6017438c4655cc0f36f2c0bc0432355/websockets-16.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:443aefe96b7fdb132e2a70806cca1f2af49bb3f28e47abcd7c2e9dcf4d8fa1b8", size = 179635, upload-time = "2026-07-17T22:50:05.001Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/08/d26d7a7628cd4ac34cbbdb63ac80914ca842ed8e42938c40a53567806df3/websockets-16.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6456ff333092d509127d75a638cb411afae8ff17f092635015d1902efec8a293", size = 177320, upload-time = "2026-07-17T22:50:06.427Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/45/ebec83e6269536aa5932533c67b0af5c781f3e73fdbcd68672dcf43f4f44/websockets-16.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fce6c48559c86d1ac3632ccb1bebc7d5442fbe79bd9bb0e40379ee54be2a4051", size = 177544, upload-time = "2026-07-17T22:50:07.834Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/d5/abc614d2297f6c1c3e01e61260364457a47c25cc1cf6a879038902bc6aa8/websockets-16.1.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:92b820d345f7a3fc7b8163949ee92df910f290c3fc517b3d5301c78065adafe1", size = 187270, upload-time = "2026-07-17T22:50:09.275Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/71/4c99af3b87dff1b2927981f6876607d4acb45338c665242168d3982f7758/websockets-16.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a606d9c24035242a3e256e9d5b77ed9cd6bccfcb7cf993e5ca3c0f6f68fb6a7", size = 188509, upload-time = "2026-07-17T22:50:10.722Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/b4/5c8ca14b0df7eb84ed0524165c5359150210140817a3312aee57bf62a1cf/websockets-16.1.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:414e596c75f74e0994084694189d7dc9229fb278e33064d6784b73ffbba3ca31", size = 189882, upload-time = "2026-07-17T22:50:12.293Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/c1/bedfba9e70557129cb8083748d167bdcc01483dedf0f0df143676df05cbe/websockets-16.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:536676848fc5961aca9d20389951f59169508f765637a172403dc5434d722fa0", size = 189114, upload-time = "2026-07-17T22:50:13.789Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/09/aa835b2787835aebd839114be5de51b797cb480b63ba42b26d34dfe147cb/websockets-16.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:97fd3a0e8b53efa41970ac1dff3d8cf0d2884cadeb4caaf95db7ad1526926ee3", size = 187861, upload-time = "2026-07-17T22:50:15.179Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/26/f6408330694dbc9830857d9d23bc14ac4f6875127a480cfdda8d5ca21198/websockets-16.1.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7b1b19636af86a3c7995d4d028dbe376f39b4bf31541146f9c123582a6c94562", size = 185286, upload-time = "2026-07-17T22:50:16.741Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/9a/e0675e70dd8a80762cf35bb18799d3f290a4890ffe6439bc51d222796083/websockets-16.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:41c8e77f17294c0ac18008a7309b99b34ee72247ef10b6dff4c3f8b5ac29896b", size = 187935, upload-time = "2026-07-17T22:50:18.213Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/c1/3234cfb86afde01b81e9bddcc6e534c440975d60a13991259e833069ab3e/websockets-16.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:9f63bcef7f4b02b06b35fc01c93b96c43b5e88e1e8868676caacf493d5a31f3a", size = 186444, upload-time = "2026-07-17T22:50:19.67Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/87/9c15206e1d778923d8daa9657de07aa62ea815e13448319c98458c37b281/websockets-16.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dab9eb87869da2d6ed3af3f3adf28414baae6ec9d4df355ffc18889132f3436c", size = 188409, upload-time = "2026-07-17T22:50:21.28Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/00/cf5de5c67676de2d3eef8b2a518f168f6796595447a5b7161ba0d012915c/websockets-16.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:43e3a9fdd7cbf7ba6040c31fae0faf84ca1474fef777c4e37912f1540f854499", size = 185958, upload-time = "2026-07-17T22:50:22.719Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/c0/731b6ddede2e4136912ec4cff2cffbda35af73546be4762c3d7bd3bd79af/websockets-16.1.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:056ae37939ed7e9974f364f5864e76e49182622d8f9751ac1903c0d09b013985", size = 186911, upload-time = "2026-07-17T22:50:24.108Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/7f/39c634472c4469a24a7c09cecddffb08fac6d0e74f73881a94ee8a40a196/websockets-16.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a0eadbbf2c30f01efa58e1f110eb6fa293261f6b0b1aa38f7f48707107690af9", size = 187204, upload-time = "2026-07-17T22:50:25.548Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/89/9667c256c256dafcc62d21328ce7a40067da857969b68ee9af375b0aaf72/websockets-16.1.1-cp314-cp314-win32.whl", hash = "sha256:195c978b065fa40910582464f99d6b15c8b314c68e0546549a55ed83f4735328", size = 179603, upload-time = "2026-07-17T22:50:27.086Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/dd/1c099d6c0fc5deb6b46ccdbb6981fdb4b12c917869cb3952408409dc18db/websockets-16.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:4e8d01cc3bcae7bbf8167f944aeafefed590fae5693552bba9794a9df68371cc", size = 179948, upload-time = "2026-07-17T22:50:28.521Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/25/9956b2d5e0529d5d23924f21bba1440d4c5c88a562e4f08550871ffa97a7/websockets-16.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0ffd3031ea8bda8d61762e84220186105ba3b748b3c8da2ae4f7816fac03e573", size = 179963, upload-time = "2026-07-17T22:50:29.982Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/06/55ffc976c488b6aee9ea05761ff7c4e88e7c1fd82818c8ca7b556ad2f90c/websockets-16.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:84a2cef8deffbd9ab8ee0ea546a2a6a7030c28f44e6cdd4547dbfeb489eb8999", size = 177497, upload-time = "2026-07-17T22:50:31.396Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/e8/f7dac2e980bacc92bdc26cebae4ae4d50cae5380732c50980598fc0bbae4/websockets-16.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3df13f73af9b3b38ab1195eb299ecb67a4330c911c97ae04043ff74085728abe", size = 177698, upload-time = "2026-07-17T22:50:32.829Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/39/26762f734113e22da2b942c3aca85798e0c0405d64c256549540ff31e5a1/websockets-16.1.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:23253dd5bcae3f9aaee0a1d30967a8dbd52e5d3cff93a2e5b84df57b77d4750d", size = 187561, upload-time = "2026-07-17T22:50:34.24Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/94/c3f330851806b9b02138b774d593478323e73c99238681b4b93efe64e02d/websockets-16.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c1c5705e314449e3308872fe084b8571ce078ee4fc55a98a769bdefe5917392", size = 188732, upload-time = "2026-07-17T22:50:36.088Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/f2/eb2c450f052de334ae33cf200ece6e87b0e14d186807074e4eb1cd2cdea2/websockets-16.1.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:69e52d175a0a7d1e13b4b67ad41c560b7d98e8c6f6126eb0bda496c784faf8c7", size = 190872, upload-time = "2026-07-17T22:50:38.008Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/31/2ac8cecf3a74f7fed9132129fc3d90b3998a1554570c11a69b2a8c20332d/websockets-16.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1f79c89b5eb034d1722938a891916582f8f7f503f58ca22518a63c3f2cd18499", size = 189305, upload-time = "2026-07-17T22:50:39.53Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/cf/8ab19650d3c0d4562c92e70ab47c257c4aa5c6a713ed87fe63766b31fefc/websockets-16.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:39f2a024af5c345ffe8fcf1ee18c049c024c94df393bb09b044a6917c77bde43", size = 188033, upload-time = "2026-07-17T22:50:40.912Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/d7/a49a38a6127a4acb134fb1912b215d900cc657605cff32445bf519f3acc4/websockets-16.1.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:952303a7318d4cbe1011400839bb2051c9f84fa0a35923267f5daba34b15d458", size = 185748, upload-time = "2026-07-17T22:50:42.559Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/3e/ad1fa40388c7f2e0bb2c7930d0090b6c5498594bd1cdaec18864df3d9e97/websockets-16.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:249116b4a76063d930a46391ad56e135c286e4562a18309029fc2c73f4ed4c62", size = 188285, upload-time = "2026-07-17T22:50:43.974Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/b8/d5db28ca264b9104f82196f92dc8843e35fd391f763d42e4ad358f5bc97e/websockets-16.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:61922544a0587a13fd3f53e4c0e5e606510c7b0d9d22c8444e5fae22a06b38cb", size = 186777, upload-time = "2026-07-17T22:50:45.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/9c/726cb39d0cc43ae848dce4aa2acb04eecc6738b1264ec6d700bf6bcfb9f8/websockets-16.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:46dcaa042cd1de6c59e7d9269fa63ff7572b6df40510600b678f0826b3c7af51", size = 188682, upload-time = "2026-07-17T22:50:46.973Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/c7/1168704de8c2dd483edabe4a22cbe4465dd8be8dd95561d214f9fe092871/websockets-16.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:38565aca3e01ea8734e578fb2118dade0ecb0250533f29e22b8d1a7a196cf4d0", size = 186377, upload-time = "2026-07-17T22:50:48.413Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/40/f9ff2d630ffce4e7dfea0b2288e1caf9ebbf9ff8a9ec9396136ce8b94935/websockets-16.1.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:42f599f4d48c7e1a3338fdaac3acd075be3b3cf02d4b274f3bf2767aedd3d217", size = 187148, upload-time = "2026-07-17T22:50:49.845Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/71/e177c8299f78d7cbe2d14df228643c10c70c0e86e108e092056bbcc16e46/websockets-16.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dcc04fedf83effaeb9cce98abc9469bb1b42ef85f03e01c8c1f4438ef7555737", size = 187578, upload-time = "2026-07-17T22:50:51.619Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/b2/b6987faf330f5af5c787a2610124c2e8403d51724f9001ec4fff6311fe7a/websockets-16.1.1-cp314-cp314t-win32.whl", hash = "sha256:8483c2096363120eea8b07c06ae7304d520f686665fffd4811fad423930a65d7", size = 179729, upload-time = "2026-07-17T22:50:53.269Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/6e/fbac6ed878dd362fbad7d415fa4f84d38e3e33fed8cde45c64e783acf826/websockets-16.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:bcce07e23e5769375158f5efdcdafa8d5cd014b93c6683865b840ed65b96f231", size = 180072, upload-time = "2026-07-17T22:50:54.969Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/4d/2d0d67834092e354d2b0498f014a41249a89556bc406cf86f3e1557bb463/websockets-16.1.1-py3-none-any.whl", hash = "sha256:6abbd3e82c731c8e531714466acd5d87b5e88ac3243465337ba71d68e23ae7e3", size = 173814, upload-time = "2026-07-17T22:51:04.184Z" },
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user