249 lines
11 KiB
Python
249 lines
11 KiB
Python
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.scheduler import next_scheduled_time
|
|
from llm_trade_bot.telegram_bot import TelegramBot
|
|
|
|
LOGGER = logging.getLogger(__name__)
|
|
|
|
|
|
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.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.telegram.start()
|
|
self._loop = asyncio.get_running_loop()
|
|
self._start_private_websocket()
|
|
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.")
|
|
|
|
async def stop(self) -> None:
|
|
self._stopping.set()
|
|
for task in self._tasks:
|
|
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):
|
|
await self.telegram.stop()
|
|
await self.database.close()
|
|
|
|
async def wait(self) -> None:
|
|
await self._stopping.wait()
|
|
|
|
def _spawn(self, awaitable: Any, name: str) -> None:
|
|
task = asyncio.create_task(awaitable, name=name)
|
|
self._tasks.add(task)
|
|
task.add_done_callback(self._task_done)
|
|
|
|
def _task_done(self, task: asyncio.Task[Any]) -> None:
|
|
self._tasks.discard(task)
|
|
if not task.cancelled() and (error := task.exception()) is not None:
|
|
LOGGER.error("Background task %s failed", task.get_name(), exc_info=error)
|
|
|
|
async def run_agent(self, trigger: str) -> str:
|
|
if self._paused:
|
|
raise RuntimeError("Сервис на паузе")
|
|
if self._run_lock.locked():
|
|
raise RuntimeError("Агент уже выполняет другой запуск")
|
|
async with self._run_lock:
|
|
await self.database.queue_message(f"🔎 Начат анализ ({trigger}).")
|
|
try:
|
|
return await self.agent.run(trigger)
|
|
except Exception as error:
|
|
await self.database.queue_message(f"❌ Ошибка анализа: {str(error)[:1000]}")
|
|
raise
|
|
|
|
async def request_run(self) -> str:
|
|
if self._paused:
|
|
return "Сервис на паузе. Сначала выполните /resume."
|
|
if self._run_lock.locked():
|
|
return "Агент уже работает."
|
|
self._spawn(self.run_agent("telegram"), "manual-agent-run")
|
|
return "Внеплановый анализ запущен."
|
|
|
|
async def pause(self) -> str:
|
|
self._paused = True
|
|
await self.database.set_state("paused", "true")
|
|
return "Новые анализы и входы приостановлены. Защитные ордера Bybit остаются активны."
|
|
|
|
async def resume(self) -> str:
|
|
self._paused = False
|
|
await self.database.set_state("paused", "false")
|
|
return "Работа возобновлена."
|
|
|
|
async def status_text(self) -> str:
|
|
next_run = next_scheduled_time(self.config.schedule)
|
|
last = await self.database.last_run()
|
|
return (
|
|
f"Режим: Bybit Demo\n"
|
|
f"Состояние: {'пауза' if self._paused else 'активен'}\n"
|
|
f"Агент: {'работает' if self._run_lock.locked() else 'ожидает'}\n"
|
|
f"Следующий запуск: {next_run.isoformat()}\n"
|
|
f"Последний запуск: {json.dumps(last, ensure_ascii=False, default=str) if last else 'нет'}"
|
|
)
|
|
|
|
async def positions_text(self) -> str:
|
|
state = await self.bybit.account_state()
|
|
return "Позиции и активы:\n" + json.dumps(
|
|
{"wallet": state["wallet"], "positions": state["positions"]}, ensure_ascii=False, default=str
|
|
)[:3800]
|
|
|
|
async def orders_text(self) -> str:
|
|
orders = await self.bybit.open_orders()
|
|
return "Открытые ордера:\n" + json.dumps(orders, ensure_ascii=False, default=str)[:3800]
|
|
|
|
async def last_run_text(self) -> str:
|
|
last = await self.database.last_run()
|
|
return json.dumps(last, ensure_ascii=False, default=str)[:4000] if last else "Запусков ещё не было."
|
|
|
|
async def notes_text(self) -> str:
|
|
notes = await self.database.recent_notes(self.config.storage.max_notes, 3500)
|
|
return "Заметки:\n" + ("\n\n".join(f"• {item}" for item in notes) if notes else "нет")
|
|
|
|
async def _scheduler_loop(self) -> None:
|
|
while not self._stopping.is_set():
|
|
scheduled = next_scheduled_time(self.config.schedule)
|
|
delay = max(0, (scheduled.astimezone(UTC) - datetime.now(UTC)).total_seconds())
|
|
try:
|
|
await asyncio.wait_for(self._stopping.wait(), timeout=delay)
|
|
return
|
|
except TimeoutError:
|
|
pass
|
|
if not self._paused:
|
|
try:
|
|
await self.run_agent(f"schedule:{scheduled.isoformat()}")
|
|
except Exception:
|
|
LOGGER.exception("Scheduled agent run failed")
|
|
|
|
async def _outbox_loop(self) -> None:
|
|
while not self._stopping.is_set():
|
|
for item in await self.database.pending_outbox():
|
|
try:
|
|
await self.telegram.send_to_admins(item.text)
|
|
await self.database.mark_outbox_sent(item.id)
|
|
except Exception as error: # noqa: BLE001 - outbox must retry any connector failure
|
|
LOGGER.warning("Telegram outbox send failed: %s", error)
|
|
await self.database.mark_outbox_failed(item.id, str(error))
|
|
try:
|
|
await asyncio.wait_for(self._stopping.wait(), timeout=5)
|
|
except TimeoutError:
|
|
pass
|
|
|
|
async def _reconcile_loop(self) -> None:
|
|
while not self._stopping.is_set():
|
|
try:
|
|
await self._cancel_expired_orders()
|
|
await self._apply_pending_trailing_stops()
|
|
except Exception:
|
|
LOGGER.exception("Bybit reconciliation failed")
|
|
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():
|
|
try:
|
|
await self.bybit.cancel_order(
|
|
category=order["category"], symbol=order["symbol"], orderId=order["order_id"]
|
|
)
|
|
await self.database.mark_managed_order_status(order["order_link_id"], "Cancelled")
|
|
await self.database.queue_message(
|
|
f"⌛ Просроченный Limit-ордер отменён: {order['category']} {order['symbol']}"
|
|
)
|
|
except Exception as error:
|
|
if "not found" not in str(error).lower() and "too late" not in str(error).lower():
|
|
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)
|
|
matching = next(
|
|
(item for item in history if item.get("orderLinkId") == managed["order_link_id"]), None
|
|
)
|
|
if matching is None or matching.get("orderStatus") != "Filled":
|
|
continue
|
|
order_link_id = managed["order_link_id"]
|
|
symbol = managed["symbol"]
|
|
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(
|
|
category="linear",
|
|
symbol=symbol,
|
|
positionIdx=0,
|
|
tpslMode="Full",
|
|
trailingStop=trailing_distance,
|
|
)
|
|
await self.database.mark_managed_order_status(order_link_id, "TrailingActive")
|
|
await self.database.queue_message(
|
|
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", []):
|
|
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]}"
|
|
)
|