From de9de755f56c28f59a140a67123d1a4304217b7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D1=83=D1=80=D0=BD=D0=B0=D1=82=20=D0=90=D0=BD=D0=B4?= =?UTF-8?q?=D1=80=D0=B5=D0=B9?= Date: Sat, 20 Jun 2026 19:22:59 +0300 Subject: [PATCH] Initial TradeBot implementation --- .dockerignore | 9 + .env.example | 73 +++ .gitattributes | 1 + .gitignore | 9 + Dockerfile | 13 + README.md | 183 ++++++ crypto_spot_bot/__init__.py | 3 + crypto_spot_bot/bot.py | 268 +++++++++ crypto_spot_bot/bybit.py | 231 ++++++++ crypto_spot_bot/config.py | 267 +++++++++ crypto_spot_bot/dashboard.py | 1000 ++++++++++++++++++++++++++++++++ crypto_spot_bot/execution.py | 354 +++++++++++ crypto_spot_bot/indicators.py | 106 ++++ crypto_spot_bot/learning.py | 407 +++++++++++++ crypto_spot_bot/llm_advisor.py | 226 ++++++++ crypto_spot_bot/main.py | 27 + crypto_spot_bot/market_data.py | 224 +++++++ crypto_spot_bot/models.py | 151 +++++ crypto_spot_bot/patterns.py | 229 ++++++++ crypto_spot_bot/storage.py | 360 ++++++++++++ crypto_spot_bot/strategy.py | 839 +++++++++++++++++++++++++++ crypto_spot_bot/time_series.py | 503 ++++++++++++++++ docker-compose.yml | 21 + pytest.ini | 4 + requirements.txt | 5 + tests/conftest.py | 92 +++ tests/test_bybit.py | 58 ++ tests/test_config.py | 63 ++ tests/test_dashboard.py | 17 + tests/test_execution.py | 131 +++++ tests/test_indicators.py | 28 + tests/test_learning.py | 99 ++++ tests/test_llm_advisor.py | 52 ++ tests/test_patterns.py | 77 +++ tests/test_strategy.py | 442 ++++++++++++++ tests/test_time_series.py | 124 ++++ tools/train_lstm_forecaster.py | 144 +++++ 37 files changed, 6840 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 crypto_spot_bot/__init__.py create mode 100644 crypto_spot_bot/bot.py create mode 100644 crypto_spot_bot/bybit.py create mode 100644 crypto_spot_bot/config.py create mode 100644 crypto_spot_bot/dashboard.py create mode 100644 crypto_spot_bot/execution.py create mode 100644 crypto_spot_bot/indicators.py create mode 100644 crypto_spot_bot/learning.py create mode 100644 crypto_spot_bot/llm_advisor.py create mode 100644 crypto_spot_bot/main.py create mode 100644 crypto_spot_bot/market_data.py create mode 100644 crypto_spot_bot/models.py create mode 100644 crypto_spot_bot/patterns.py create mode 100644 crypto_spot_bot/storage.py create mode 100644 crypto_spot_bot/strategy.py create mode 100644 crypto_spot_bot/time_series.py create mode 100644 docker-compose.yml create mode 100644 pytest.ini create mode 100644 requirements.txt create mode 100644 tests/conftest.py create mode 100644 tests/test_bybit.py create mode 100644 tests/test_config.py create mode 100644 tests/test_dashboard.py create mode 100644 tests/test_execution.py create mode 100644 tests/test_indicators.py create mode 100644 tests/test_learning.py create mode 100644 tests/test_llm_advisor.py create mode 100644 tests/test_patterns.py create mode 100644 tests/test_strategy.py create mode 100644 tests/test_time_series.py create mode 100644 tools/train_lstm_forecaster.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3b026c2 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +__pycache__/ +*.py[cod] +.pytest_cache/ +.pytest_tmp/ +.venv/ +venv/ +.env +runtime/ +.git/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2fe2ada --- /dev/null +++ b/.env.example @@ -0,0 +1,73 @@ +TRADING_MODE=paper +HOST=127.0.0.1 +PORT=8787 + +BYBIT_TESTNET=false +BYBIT_API_KEY= +BYBIT_API_SECRET= + +STARTING_BALANCE_USDT=100 +AUTO_SELECT_SYMBOLS=true +TOP_SYMBOLS_COUNT=6 +SYMBOLS= + +BASE_INTERVAL=1 +KLINE_LIMIT=240 +LOOP_INTERVAL_SECONDS=5 +FAST_TRADING_ENABLED=false +FAST_LOOP_INTERVAL_SECONDS=1 +FAST_ENTRY_COOLDOWN_SECONDS=20 +MAX_ENTRIES_PER_MINUTE=12 +WEBSOCKET_ENABLED=true +MIN_SIGNAL_CONFIDENCE=0.64 +MAX_SPREAD_PERCENT=0.18 +MIN_24H_TURNOVER_USDT=1000000 +PATTERN_ANALYSIS_ENABLED=true +PATTERN_SCORE_WEIGHT=0.18 +LEARNING_ENABLED=true +LEARNING_LOOKBACK_TRADES=120 +LEARNING_MIN_SAMPLES=3 +LEARNING_MAX_ADJUSTMENT=0.12 +MIN_POSITION_USDT=1 +MAX_POSITION_USDT=20 +MAX_SYMBOL_EXPOSURE_USDT=20 +MAX_TOTAL_EXPOSURE_USDT=80 +MAX_OPEN_POSITIONS=80 +MAX_POSITIONS_PER_SYMBOL=20 +GRID_TRADING_ENABLED=true +GRID_ENTRY_CONFIDENCE=0.58 +GRID_BUY_ZONE=0.45 +GRID_MAX_POSITION_USDT=8 +REBOUND_TRADING_ENABLED=true +REBOUND_ENTRY_CONFIDENCE=0.58 +REBOUND_MIN_PROBABILITY=0.58 +REBOUND_MAX_POSITION_USDT=6 +TIME_SERIES_FORECAST_ENABLED=true +TIME_SERIES_MIN_CANDLES=120 +TIME_SERIES_VALIDATION_WINDOW=30 +TIME_SERIES_FORECAST_HORIZON=3 +TIME_SERIES_EWMA_LAMBDA=0.94 +TIME_SERIES_MIN_EDGE_PERCENT=0.04 +TIME_SERIES_MAX_ADJUSTMENT=0.08 +TIME_SERIES_LSTM_ENABLED=true +TIME_SERIES_LSTM_LOOKBACK=32 +TIME_SERIES_LSTM_UNITS=6 +TIME_SERIES_LSTM_RIDGE=0.0001 +TIME_SERIES_LSTM_MODEL_PATH=runtime/lstm_forecaster.json +STOP_LOSS_PERCENT=0.02 +TAKE_PROFIT_PERCENT=0.035 +TRAILING_STOP_PERCENT=0.015 +MIN_HOLD_SECONDS=180 +ENTRY_COOLDOWN_SECONDS=180 +MAX_DAILY_DRAWDOWN_USDT=6 +MIN_CASH_RESERVE_USDT=5 +TAKER_FEE_RATE=0.001 +SLIPPAGE_RATE=0.0003 + +# Real trading is locked unless all three values are set explicitly. +ENABLE_LIVE_TRADING=false +LIVE_TRADING_CONFIRM= +LIVE_ORDER_MAX_USDT=10 + +DATABASE_PATH=runtime/tradebot.sqlite3 +LOG_PATH=runtime/tradebot.log diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e43b44c --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +__pycache__/ +*.py[cod] +.pytest_cache/ +.pytest_tmp/ +.venv/ +venv/ +.env +runtime/ +*.log diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..211c1a2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM python:3.12-slim + +WORKDIR /app +COPY requirements.txt /app/requirements.txt +RUN pip install --no-cache-dir --upgrade pip \ + && pip install --no-cache-dir -r /app/requirements.txt + +COPY crypto_spot_bot /app/crypto_spot_bot +COPY README.md /app/README.md +RUN mkdir -p /app/runtime + +EXPOSE 8787 +CMD ["python", "-m", "crypto_spot_bot.main"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..08871cc --- /dev/null +++ b/README.md @@ -0,0 +1,183 @@ +# Crypto Spot TradeBot + +Spot-бот для демо-торговли криптовалютой на реальных данных Bybit. По умолчанию работает только в `paper`-режиме со стартовым балансом `100 USDT`; live-режим заблокирован до явного включения через env-переменные. + +## Что реализовано + +- Реальные market data Bybit Spot: REST bootstrap и WebSocket-обновления. +- Автовыбор популярных USDT spot-пар по `turnover24h`. +- Paper trading с учетом cash, комиссий, проскальзывания, stop-loss, take-profit и trailing stop. +- Spot-only логика: покупка базовой монеты за USDT и продажа обратно, без short и без плеча. +- Live spot-ордеры явно отправляются без плеча: `category=spot`, `isLeverage=0`. +- Анализ шаблонов рынка: трендовый откат, пробой вверх/вниз, ускоренное падение, боковик, перепроданность с разворотом и объемный всплеск. +- Обучение на закрытых сделках: статистика PnL и win rate по символам и шаблонам входа корректирует уверенность новых входов в заданных пределах. +- LLM Advisor выключен по умолчанию; стратегия, обучение, grid и rebound работают без запросов к Ollama. +- Динамический размер позиции: стратегия записывает в сигнал размер входа в пределах `MIN_POSITION_USDT`..`MAX_POSITION_USDT`, а брокер ограничивает суммарную экспозицию по паре через `MAX_SYMBOL_EXPOSURE_USDT`. +- Автоматический grid-режим: бот включает grid-входы на боковике, покупает только в нижней части диапазона и выключает grid при падающих/опасных режимах. +- Вероятностный rebound-вход: после снижения бот отдельно оценивает стабилизацию, отскок от локального low, RSI, объем и рыночные ограничения; такой вход ограничен меньшим размером позиции. +- Прогнозирование временных рядов: walk-forward выбор между `naive`, `drift`, `EWMA`, `AR(1)`, `AR(3)` и легким `lstm`-кандидатом для ожидаемой доходности плюс EWMA/GARCH-like прогноз волатильности. Прогноз влияет и на новые покупки, и на раннюю продажу при ухудшении ожидаемого движения. +- Защитные блокировки входа: явно отрицательные LONG-шаблоны и setups с сильной отрицательной статистикой обучения запрещают новые покупки. +- Быстрый режим торговли: отдельный короткий интервал цикла, короткий cooldown после выхода и лимит новых входов в минуту; выходы по риску этим лимитом не блокируются. +- Веб-dashboard на русском: equity, cash, PnL, позиции, сделки, сигналы, события, свечные графики, переключатель быстрой торговли и индикаторы работы обучения. +- SQLite runtime-хранилище в `runtime/tradebot.sqlite3`. +- Health endpoint `/api/health` и Prometheus-compatible `/metrics`. +- Docker Compose для установки на Raspberry Pi 5 или другой Linux-хост. +- Live trading guard: live не стартует без `ENABLE_LIVE_TRADING=true`, `LIVE_TRADING_CONFIRM=I_ACCEPT_REAL_RISK` и Bybit API-ключей. + +## Источники и принятые параметры + +Официальная документация Bybit V5 указывает, что единый V5 API использует параметр `category`, включая `spot`; поэтому бот везде запрашивает `category=spot` и не использует futures/linear endpoints: . + +Список инструментов Bybit Spot берется из `/v5/market/instruments-info`; документация Bybit описывает для Spot поля `baseCoin`, `quoteCoin`, `status`, `priceFilter`, `lotSizeFilter`, `basePrecision` и `minOrderAmt`, поэтому размеры paper/live-ордеров в коде валидируются по данным инструмента: . + +Популярность пар определяется через `/v5/market/tickers`, потому что Bybit Spot ticker возвращает `turnover24h`, `volume24h`, `bid1Price`, `ask1Price` и `lastPrice`: . + +Лучшие bid/ask берутся из `/v5/market/orderbook`; документация Bybit описывает `GET /v5/market/orderbook` с `category=spot`: . + +WebSocket-стакан использует topic `orderbook.{depth}.{symbol}`; Bybit документирует snapshot/delta-поведение и частоты push для Spot depth 1/50/200/1000: . + +Live market orders используют `/v5/order/create`; Bybit документирует для Spot `orderType=Market`, `side`, `qty`, `category=spot`, а для market buy по умолчанию qty может быть в quote currency через `marketUnit=quoteCoin`: . + +Функции уровня коммерческих automated trading systems взяты из проверяемых источников: + +- Investopedia перечисляет важные свойства algo trading software: real-time market data, low latency, configurability, backtesting, broker/exchange integration, fees/costs и APIs: . +- Investopedia отдельно указывает, что automated trading systems задают правила entry/exit/money management, но требуют мониторинга и несут риск mechanical failures и over-optimization: . +- QuantInsti описывает типовой путь разработки: стратегия, backtesting, paper trading, затем live trading, плюс GUI, order management и risk management: . +- Документация `statsmodels` описывает ARIMA как общий интерфейс для AR/MA/ARMA/ARIMA/SARIMA-моделей; в боте используется легкий AR(1)/AR(3) вариант без добавления тяжелой зависимости `statsmodels`: . +- Документация `arch` описывает GARCH(p,q) как модель для прогнозирования волатильности; в боте используется фиксированная GARCH(1,1)-подобная рекурсия без MLE-оценки параметров, чтобы сохранить легкий runtime на Raspberry Pi: . +- RiskMetrics описывает EWMA-подход к оценке волатильности через коэффициент затухания; в боте `TIME_SERIES_EWMA_LAMBDA=0.94` используется как настраиваемое значение по умолчанию: . +- Hochreiter и Schmidhuber описали LSTM как recurrent neural network architecture для последовательностей; в боте используется легкая LSTM-reservoir рекурсия с ridge-readout, а не полноценное PyTorch/TensorFlow обучение внутри Docker: . + +Я не могу подтвердить, что эта стратегия будет прибыльной. Источники выше описывают технические свойства и риски автоматической торговли, но не гарантируют прибыль. + +## Быстрый старт локально + +```powershell +python -m venv .venv +.venv\Scripts\Activate.ps1 +pip install -r requirements.txt +Copy-Item .env.example .env +python -m crypto_spot_bot.main +``` + +Dashboard: + +## Локальное обучение LSTM-кандидата + +Обучение можно запускать на основной машине, а Raspberry Pi оставлять только для исполнения торгового цикла. Команда ниже берет spot-свечи Bybit, перебирает `lookback`, `units` и `ridge`, оценивает LSTM-кандидат через walk-forward MAE и сохраняет параметры в `runtime/lstm_forecaster.json`: + +```powershell +python tools\train_lstm_forecaster.py --symbols BTCUSDT,ETHUSDT,SOLUSDT,XRPUSDT,LTCUSDT --limit 1000 +``` + +Файл из `TIME_SERIES_LSTM_MODEL_PATH` читается ботом автоматически. Даже если LSTM-параметры сохранены, сделка меняется только тогда, когда текущая walk-forward проверка в `crypto_spot_bot/time_series.py` показывает качество лучше baseline. + +## Docker + +```bash +cp .env.example .env +docker compose up -d --build +docker compose logs -f tradebot +``` + +Dashboard: `http://:8787/` + +Для Raspberry Pi 5 проект использует `python:3.12-slim`, без Node.js build step. Runtime-данные лежат в volume `./runtime:/app/runtime`; на внешнем диске можно разместить папку проекта или заменить volume на абсолютный путь внешнего диска. + +## Основные env-параметры + +```env +TRADING_MODE=paper +STARTING_BALANCE_USDT=100 +AUTO_SELECT_SYMBOLS=true +TOP_SYMBOLS_COUNT=6 +BASE_INTERVAL=1 +LOOP_INTERVAL_SECONDS=5 +FAST_TRADING_ENABLED=false +FAST_LOOP_INTERVAL_SECONDS=1 +FAST_ENTRY_COOLDOWN_SECONDS=20 +MAX_ENTRIES_PER_MINUTE=12 +WEBSOCKET_ENABLED=true +MIN_SIGNAL_CONFIDENCE=0.64 +PATTERN_ANALYSIS_ENABLED=true +PATTERN_SCORE_WEIGHT=0.18 +LEARNING_ENABLED=true +LEARNING_LOOKBACK_TRADES=120 +LEARNING_MIN_SAMPLES=3 +LEARNING_MAX_ADJUSTMENT=0.12 +MIN_POSITION_USDT=1 +MAX_POSITION_USDT=20 +MAX_SYMBOL_EXPOSURE_USDT=20 +MAX_TOTAL_EXPOSURE_USDT=80 +MAX_OPEN_POSITIONS=80 +MAX_POSITIONS_PER_SYMBOL=20 +GRID_TRADING_ENABLED=true +GRID_ENTRY_CONFIDENCE=0.58 +GRID_BUY_ZONE=0.45 +GRID_MAX_POSITION_USDT=8 +REBOUND_TRADING_ENABLED=true +REBOUND_ENTRY_CONFIDENCE=0.58 +REBOUND_MIN_PROBABILITY=0.58 +REBOUND_MAX_POSITION_USDT=6 +TIME_SERIES_FORECAST_ENABLED=true +TIME_SERIES_MIN_CANDLES=120 +TIME_SERIES_VALIDATION_WINDOW=30 +TIME_SERIES_FORECAST_HORIZON=3 +TIME_SERIES_EWMA_LAMBDA=0.94 +TIME_SERIES_MIN_EDGE_PERCENT=0.04 +TIME_SERIES_MAX_ADJUSTMENT=0.08 +TIME_SERIES_LSTM_ENABLED=true +TIME_SERIES_LSTM_LOOKBACK=32 +TIME_SERIES_LSTM_UNITS=6 +TIME_SERIES_LSTM_RIDGE=0.0001 +TIME_SERIES_LSTM_MODEL_PATH=runtime/lstm_forecaster.json +STOP_LOSS_PERCENT=0.02 +TAKE_PROFIT_PERCENT=0.035 +TRAILING_STOP_PERCENT=0.015 +MIN_HOLD_SECONDS=180 +ENTRY_COOLDOWN_SECONDS=180 +MAX_DAILY_DRAWDOWN_USDT=6 +TAKER_FEE_RATE=0.001 +SLIPPAGE_RATE=0.0003 +``` + +## Быстрая торговля + +Быстрый режим включается через web-переключатель или через `FAST_TRADING_ENABLED=true`. Тогда фактический цикл принятия решений берется из `FAST_LOOP_INTERVAL_SECONDS`, а cooldown после закрытия позиции — из `FAST_ENTRY_COOLDOWN_SECONDS`. Параметр `MAX_ENTRIES_PER_MINUTE` ограничивает только новые покупки; продажи по stop-loss, take-profit, trailing stop и другим правилам выхода не блокируются этим лимитом. + +Для быстрого режима рекомендуется оставлять `WEBSOCKET_ENABLED=true`: WebSocket дает частые рыночные обновления, а REST используется как периодическая сверка. Я не могу подтвердить, что быстрый режим повысит прибыльность; он только уменьшает техническую задержку реакции стратегии. + +## Live-режим + +Live-режим специально заблокирован. Для включения нужны все значения: + +```env +TRADING_MODE=live +ENABLE_LIVE_TRADING=true +LIVE_TRADING_CONFIRM=I_ACCEPT_REAL_RISK +BYBIT_API_KEY=... +BYBIT_API_SECRET=... +LIVE_ORDER_MAX_USDT=10 +``` + +Текущее live-исполнение отправляет market buy/sell в Bybit и ведет локальную shadow-позицию для dashboard и правил выхода. Для промышленной торговли реальными средствами следующий обязательный шаг — reconciliation с реальным wallet/order history Bybit, чтобы локальное состояние сверялось с фактическими fills и балансами. + +## API + +- `GET /api/health` — healthcheck. +- `GET /api/status` — статус бота, account snapshot, позиции. +- `GET /api/markets` — пары, ticker, свечи, инструменты. +- `GET /api/trades` — последние сделки. +- `GET /api/signals` — последние сигналы стратегии. +- `GET /api/events` — события. +- `GET /api/config` — безопасная конфигурация без секретов. +- `POST /api/config/fast-trading` — включение/выключение быстрой торговли из dashboard. +- `POST /api/control/start` — старт цикла. +- `POST /api/control/stop` — остановка цикла. +- `GET /metrics` — Prometheus-compatible метрики. + +## Проверка + +```bash +python -m pytest +``` diff --git a/crypto_spot_bot/__init__.py b/crypto_spot_bot/__init__.py new file mode 100644 index 0000000..45b9c54 --- /dev/null +++ b/crypto_spot_bot/__init__.py @@ -0,0 +1,3 @@ +"""Crypto spot trading bot package.""" + +__version__ = "0.1.0" diff --git a/crypto_spot_bot/bot.py b/crypto_spot_bot/bot.py new file mode 100644 index 0000000..257f580 --- /dev/null +++ b/crypto_spot_bot/bot.py @@ -0,0 +1,268 @@ +from __future__ import annotations + +import asyncio +from datetime import datetime + +from crypto_spot_bot.config import Settings +from crypto_spot_bot.execution import LiveBroker, PaperBroker +from crypto_spot_bot.learning import TradeLearner +from crypto_spot_bot.market_data import MarketData +from crypto_spot_bot.models import BotStatus, Signal, utc_now +from crypto_spot_bot.patterns import PatternAnalyzer +from crypto_spot_bot.strategy import SpotStrategy +from crypto_spot_bot.storage import Storage +from crypto_spot_bot.time_series import TimeSeriesForecaster + + +class CryptoSpotBot: + def __init__( + self, + settings: Settings, + storage: Storage, + market: MarketData, + broker: PaperBroker | LiveBroker, + strategy: SpotStrategy, + pattern_analyzer: PatternAnalyzer, + learner: TradeLearner, + forecaster: TimeSeriesForecaster | None = None, + llm_advisor=None, + ): + self.settings = settings + self.storage = storage + self.market = market + self.broker = broker + self.strategy = strategy + self.pattern_analyzer = pattern_analyzer + self.learner = learner + self.forecaster = forecaster + self.llm_advisor = llm_advisor + self.running = False + self.started_at: datetime | None = None + self.last_loop_at: datetime | None = None + self.message = "бот остановлен" + self._entry_cooldown_until: dict[str, datetime] = {} + self._loop_task: asyncio.Task | None = None + self._ws_task: asyncio.Task | None = None + + async def start(self) -> None: + if self.running: + return + self.market.reset_stop() + if not self.market.symbols: + await self.market.bootstrap() + self._update_patterns() + self._update_forecasts() + self.learner.refresh() + self.running = True + self.started_at = utc_now() + self.message = "бот работает" + self.storage.event("Бот запущен") + if self.settings.websocket_enabled: + self._ws_task = asyncio.create_task(self.market.websocket_loop()) + self._loop_task = asyncio.create_task(self._run_loop()) + + async def stop(self) -> None: + self.running = False + self.message = "бот остановлен" + self.market.stop() + tasks = [task for task in (self._loop_task, self._ws_task) if task] + for task in tasks: + task.cancel() + if tasks: + await asyncio.gather(*tasks, return_exceptions=True) + self.storage.event("Бот остановлен") + + async def _run_loop(self) -> None: + while self.running: + try: + rest_refresh_seconds = self._rest_refresh_seconds() + if self._needs_rest_refresh(rest_refresh_seconds): + await asyncio.to_thread(self.market.refresh_rest) + self.broker.update_highs(self.market.tickers) + self._update_patterns() + self._update_forecasts() + self.learner.refresh() + await self._process_exits() + await self._process_entries() + self.broker.mark_equity(self.market.prices()) + self.last_loop_at = utc_now() + except asyncio.CancelledError: + raise + except Exception as exc: + self.message = f"ошибка цикла: {exc}" + self.storage.event(self.message, "ERROR") + await asyncio.sleep(self.settings.effective_loop_interval_seconds) + + def _needs_rest_refresh(self, rest_refresh_seconds: float) -> bool: + if self.market.last_rest_refresh_at is None: + return True + age = (utc_now() - self.market.last_rest_refresh_at).total_seconds() + return age >= rest_refresh_seconds + + def _rest_refresh_seconds(self) -> float: + if self.settings.websocket_enabled: + return 10.0 if self.settings.fast_trading_enabled else 20.0 + return self.settings.effective_loop_interval_seconds + + async def _process_exits(self) -> None: + prices = self.market.prices() + reduction_candidate_id = self._reduction_candidate_id(prices) + for position in list(self.broker.open_positions()): + ticker = self.market.tickers.get(position.symbol) + candles = self.market.candles.get(position.symbol, []) + forecast = self.market.forecasts.get(position.symbol, {}) + adaptive_rules = self._with_exposure_context(self.learner.rules_for(position.symbol, position.entry_pattern)) + adaptive_rules["reduce_now"] = position.id is not None and position.id == reduction_candidate_id + learning = {"adaptive_rules": adaptive_rules} + signal = self.strategy.exit_signal(position, candles, ticker, learning, forecast) + self.storage.insert_signal(signal) + if signal.action == "SELL" and ticker is not None: + self.broker.sell(position, ticker, signal.reason) + self._entry_cooldown_until[position.symbol] = utc_now() + + async def _process_entries(self) -> None: + prices = self.market.prices() + for symbol in self.market.symbols: + cooldown_since = self._entry_cooldown_until.get(symbol) + if cooldown_since: + age = (utc_now() - cooldown_since).total_seconds() + cooldown_seconds = self.settings.effective_entry_cooldown_seconds + if age < cooldown_seconds: + self.storage.insert_signal( + Signal( + symbol, + "HOLD", + 0.0, + "пауза после закрытия позиции", + {"cooldown_remaining_seconds": cooldown_seconds - age}, + ) + ) + continue + self._entry_cooldown_until.pop(symbol, None) + ticker = self.market.tickers.get(symbol) + candles = self.market.candles.get(symbol, []) + open_count = len(self.broker.positions_for_symbol(symbol)) + pattern = self.market.patterns.get(symbol, {}) + forecast = self.market.forecasts.get(symbol, {}) + learning = self.learner.adjustment_for(symbol, str(pattern.get("label", ""))).as_dict() + learning["adaptive_rules"] = self._with_exposure_context(learning.get("adaptive_rules") or {}) + llm = {} + if ( + self.settings.llm_advisor_enabled + and self.llm_advisor is not None + and ticker is not None + and len(candles) >= 200 + ): + llm = ( + await asyncio.to_thread( + self.llm_advisor.advice_for, + symbol=symbol, + candles=candles, + ticker=ticker, + pattern=pattern, + learning=learning, + open_positions_for_symbol=open_count, + account=self.broker.account_state(prices), + ) + ).as_dict() + signal = self.strategy.entry_signal(symbol, candles, ticker, open_count, pattern, learning, llm, forecast) + self.storage.insert_signal(signal) + if signal.action == "BUY" and ticker is not None: + self.broker.buy( + signal, + ticker, + self.market.instruments.get(symbol), + prices, + ) + + def _with_exposure_context(self, rules: dict) -> dict: + enriched = dict(rules) + current_exposure = self.broker.exposure() + target_total = float(enriched.get("target_total_exposure_usdt", self.settings.max_total_exposure_usdt) or 0.0) + target_total = max(0.0, min(self.settings.max_total_exposure_usdt, target_total)) + enriched["current_total_exposure_usdt"] = round(current_exposure, 6) + enriched["target_total_exposure_usdt"] = round(target_total, 6) + enriched["over_target_exposure"] = current_exposure > target_total + self.settings.min_position_usdt + return enriched + + def _reduction_candidate_id(self, prices: dict[str, float]) -> int | None: + rules = self._with_exposure_context(self.learner.state.adaptive_rules or {}) + if not rules.get("reduce_exposure") or not rules.get("over_target_exposure"): + return None + positions = self.broker.open_positions() + if not positions: + return None + + def loss_ratio(position) -> float: + mark = prices.get(position.symbol, position.entry_price) + if position.notional_usdt <= 0: + return 0.0 + return position.unrealized_pnl(mark) / position.notional_usdt + + worst = min(positions, key=loss_ratio) + return worst.id + + def _update_patterns(self) -> None: + if not self.settings.pattern_analysis_enabled: + self.market.patterns = {} + return + patterns: dict[str, dict] = {} + for symbol in self.market.symbols: + result = self.pattern_analyzer.analyze( + self.market.candles.get(symbol, []), + self.market.tickers.get(symbol), + ) + patterns[symbol] = result.as_dict() + self.market.patterns = patterns + + def _update_forecasts(self) -> None: + if self.forecaster is None or not self.settings.time_series_forecast_enabled: + self.market.forecasts = {} + return + forecasts: dict[str, dict] = {} + for symbol in self.market.symbols: + forecasts[symbol] = self.forecaster.forecast( + self.market.candles.get(symbol, []), + symbol=symbol, + ).as_dict() + self.market.forecasts = forecasts + + def status(self) -> BotStatus: + return BotStatus( + running=self.running, + mode=self.settings.trading_mode, + live_trading_ready=self.settings.live_ready, + symbols=self.market.symbols, + started_at=self.started_at, + last_loop_at=self.last_loop_at, + message=self.message, + ) + + def account_snapshot(self) -> dict[str, float]: + prices = self.market.prices() + state = self.broker.account_state(prices) + state["starting_balance"] = self.settings.starting_balance_usdt + state["net_pnl"] = state["equity"] - self.settings.starting_balance_usdt + state["net_pnl_percent"] = ( + (state["net_pnl"] / self.settings.starting_balance_usdt) * 100 + if self.settings.starting_balance_usdt + else 0.0 + ) + return state + + def positions_snapshot(self) -> list[dict]: + prices = self.market.prices() + return [ + position.as_dict(mark_price=prices.get(position.symbol, position.entry_price)) + for position in self.broker.open_positions() + ] + + def learning_snapshot(self) -> dict: + snapshot = self.learner.state.as_dict() + snapshot["adaptive_rules"] = self._with_exposure_context(snapshot.get("adaptive_rules") or {}) + return snapshot + + def llm_snapshot(self) -> dict: + if self.llm_advisor is None: + return {"enabled": False, "items": []} + return self.llm_advisor.snapshot() diff --git a/crypto_spot_bot/bybit.py b/crypto_spot_bot/bybit.py new file mode 100644 index 0000000..b3143de --- /dev/null +++ b/crypto_spot_bot/bybit.py @@ -0,0 +1,231 @@ +from __future__ import annotations + +import hashlib +import hmac +import json +import time +from dataclasses import dataclass +from typing import Any +from urllib.parse import urlencode + +import requests + +from crypto_spot_bot.config import Settings +from crypto_spot_bot.models import Candle, Ticker + + +class BybitError(RuntimeError): + pass + + +def _float(value: Any, default: float = 0.0) -> float: + try: + return float(value) + except (TypeError, ValueError): + return default + + +@dataclass(slots=True) +class Instrument: + symbol: str + base_coin: str + quote_coin: str + status: str + tick_size: float + qty_step: float + min_order_qty: float + min_notional_value: float + + +class BybitClient: + def __init__(self, settings: Settings): + self.settings = settings + self.session = requests.Session() + + def public_get(self, path: str, params: dict[str, Any]) -> dict[str, Any]: + response = self.session.get( + f"{self.settings.rest_base_url}{path}", + params=params, + timeout=12, + ) + response.raise_for_status() + return self._unwrap(response.json()) + + def private_post(self, path: str, payload: dict[str, Any]) -> dict[str, Any]: + body = json.dumps(payload, separators=(",", ":"), ensure_ascii=False) + headers = self._headers(body) + response = self.session.post( + f"{self.settings.rest_base_url}{path}", + data=body.encode("utf-8"), + headers=headers, + timeout=15, + ) + response.raise_for_status() + return self._unwrap(response.json()) + + def _headers(self, payload: str) -> dict[str, str]: + timestamp = str(int(time.time() * 1000)) + recv_window = "5000" + sign_payload = timestamp + self.settings.bybit_api_key + recv_window + payload + signature = hmac.new( + self.settings.bybit_api_secret.encode("utf-8"), + sign_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + return { + "X-BAPI-API-KEY": self.settings.bybit_api_key, + "X-BAPI-TIMESTAMP": timestamp, + "X-BAPI-RECV-WINDOW": recv_window, + "X-BAPI-SIGN": signature, + "Content-Type": "application/json", + } + + @staticmethod + def _unwrap(data: dict[str, Any]) -> dict[str, Any]: + if int(data.get("retCode", -1)) != 0: + raise BybitError(f"{data.get('retCode')}: {data.get('retMsg')}") + result = data.get("result") + if not isinstance(result, dict): + raise BybitError("Bybit returned unexpected result payload") + return result + + def spot_tickers(self) -> list[Ticker]: + result = self.public_get("/v5/market/tickers", {"category": "spot"}) + tickers: list[Ticker] = [] + for row in result.get("list", []): + symbol = str(row.get("symbol", "")).upper() + last = _float(row.get("lastPrice")) + if not symbol or last <= 0: + continue + tickers.append( + Ticker( + symbol=symbol, + last_price=last, + bid=_float(row.get("bid1Price")), + ask=_float(row.get("ask1Price")), + turnover_24h=_float(row.get("turnover24h")), + volume_24h=_float(row.get("volume24h")), + change_24h=_float(row.get("price24hPcnt")) * 100, + ) + ) + return tickers + + def instruments(self) -> dict[str, Instrument]: + result = self.public_get("/v5/market/instruments-info", {"category": "spot"}) + instruments: dict[str, Instrument] = {} + for row in result.get("list", []): + lot = row.get("lotSizeFilter") or {} + price = row.get("priceFilter") or {} + symbol = str(row.get("symbol", "")).upper() + if not symbol: + continue + instruments[symbol] = Instrument( + symbol=symbol, + base_coin=str(row.get("baseCoin", "")), + quote_coin=str(row.get("quoteCoin", "")), + status=str(row.get("status", "")), + tick_size=_float(price.get("tickSize")), + qty_step=_float(lot.get("qtyStep"), _float(lot.get("basePrecision"))), + min_order_qty=_float(lot.get("minOrderQty")), + min_notional_value=_float(lot.get("minNotionalValue"), _float(lot.get("minOrderAmt"))), + ) + return instruments + + def popular_spot_symbols(self, limit: int) -> list[str]: + instruments = self.instruments() + rows = [] + for ticker in self.spot_tickers(): + info = instruments.get(ticker.symbol) + if ( + info + and info.quote_coin == "USDT" + and info.status == "Trading" + and ticker.turnover_24h > 0 + and not _looks_like_leveraged_token(info.base_coin) + and not _looks_like_stablecoin(info.base_coin) + ): + rows.append((ticker.turnover_24h, ticker.symbol)) + rows.sort(reverse=True) + return [symbol for _, symbol in rows[:limit]] + + def klines(self, symbol: str, interval: str, limit: int) -> list[Candle]: + result = self.public_get( + "/v5/market/kline", + {"category": "spot", "symbol": symbol, "interval": interval, "limit": limit}, + ) + candles = [] + for row in result.get("list", []): + if len(row) < 7: + continue + candles.append( + Candle( + timestamp=int(row[0]), + open=_float(row[1]), + high=_float(row[2]), + low=_float(row[3]), + close=_float(row[4]), + volume=_float(row[5]), + turnover=_float(row[6]), + ) + ) + candles.sort(key=lambda item: item.timestamp) + return candles + + def orderbook_top(self, symbol: str) -> tuple[float, float]: + result = self.public_get( + "/v5/market/orderbook", + {"category": "spot", "symbol": symbol, "limit": 1}, + ) + bids = result.get("b") or [] + asks = result.get("a") or [] + bid = _float(bids[0][0]) if bids else 0.0 + ask = _float(asks[0][0]) if asks else 0.0 + return bid, ask + + def place_spot_market_order( + self, + symbol: str, + side: str, + qty: float, + market_unit: str, + order_link_id: str, + ) -> dict[str, Any]: + payload = { + "category": "spot", + "symbol": symbol, + "side": side, + "orderType": "Market", + "qty": f"{qty:.8f}".rstrip("0").rstrip("."), + "timeInForce": "IOC", + "isLeverage": 0, + "orderFilter": "Order", + "marketUnit": market_unit, + "orderLinkId": order_link_id, + } + return self.private_post("/v5/order/create", payload) + + +def websocket_subscribe_message(symbols: list[str]) -> str: + args: list[str] = [] + for symbol in symbols: + args.extend([f"tickers.{symbol}", f"kline.1.{symbol}", f"orderbook.1.{symbol}"]) + return json.dumps({"op": "subscribe", "args": args}) + + +def _looks_like_leveraged_token(base_coin: str) -> bool: + upper = base_coin.upper() + return upper.endswith(("3L", "3S", "2L", "2S", "5L", "5S", "UP", "DOWN")) + + +def _looks_like_stablecoin(base_coin: str) -> bool: + return base_coin.upper() in { + "USDC", + "USDT", + "DAI", + "TUSD", + "FDUSD", + "USDE", + "USDD", + "PYUSD", + "USD1", + } diff --git a/crypto_spot_bot/config.py b/crypto_spot_bot/config.py new file mode 100644 index 0000000..3242a06 --- /dev/null +++ b/crypto_spot_bot/config.py @@ -0,0 +1,267 @@ +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path + + +def _load_dotenv(path: Path) -> None: + if not path.exists(): + return + for raw in path.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + key = key.strip() + value = value.strip().strip('"').strip("'") + os.environ.setdefault(key, value) + + +def _bool_env(name: str, default: bool) -> bool: + raw = os.getenv(name) + if raw is None or raw == "": + return default + return raw.strip().lower() in {"1", "true", "yes", "y", "on"} + + +def _float_env(name: str, default: float) -> float: + raw = os.getenv(name) + return default if raw in (None, "") else float(raw) + + +def _int_env(name: str, default: int) -> int: + raw = os.getenv(name) + return default if raw in (None, "") else int(raw) + + +def _symbols_env(name: str) -> tuple[str, ...]: + raw = os.getenv(name, "") + return tuple(part.strip().upper() for part in raw.split(",") if part.strip()) + + +@dataclass +class Settings: + trading_mode: str + host: str + port: int + bybit_testnet: bool + bybit_api_key: str + bybit_api_secret: str + starting_balance_usdt: float + auto_select_symbols: bool + top_symbols_count: int + symbols: tuple[str, ...] + base_interval: str + kline_limit: int + loop_interval_seconds: int + fast_trading_enabled: bool + fast_loop_interval_seconds: float + fast_entry_cooldown_seconds: int + max_entries_per_minute: int + websocket_enabled: bool + min_signal_confidence: float + max_spread_percent: float + min_24h_turnover_usdt: float + pattern_analysis_enabled: bool + pattern_score_weight: float + learning_enabled: bool + learning_lookback_trades: int + learning_min_samples: int + learning_max_adjustment: float + llm_advisor_enabled: bool + ollama_base_url: str + ollama_model: str + llm_advisor_min_interval_seconds: int + llm_advisor_timeout_seconds: int + llm_advisor_max_adjustment: float + min_position_usdt: float + max_position_usdt: float + max_symbol_exposure_usdt: float + max_total_exposure_usdt: float + max_open_positions: int + max_positions_per_symbol: int + grid_trading_enabled: bool + grid_entry_confidence: float + grid_buy_zone: float + grid_max_position_usdt: float + rebound_trading_enabled: bool + rebound_entry_confidence: float + rebound_min_probability: float + rebound_max_position_usdt: float + time_series_forecast_enabled: bool + time_series_min_candles: int + time_series_validation_window: int + time_series_forecast_horizon: int + time_series_ewma_lambda: float + time_series_min_edge_percent: float + time_series_max_adjustment: float + time_series_lstm_enabled: bool + time_series_lstm_lookback: int + time_series_lstm_units: int + time_series_lstm_ridge: float + time_series_lstm_model_path: Path + stop_loss_percent: float + take_profit_percent: float + trailing_stop_percent: float + min_hold_seconds: int + entry_cooldown_seconds: int + max_daily_drawdown_usdt: float + min_cash_reserve_usdt: float + taker_fee_rate: float + slippage_rate: float + enable_live_trading: bool + live_trading_confirm: str + live_order_max_usdt: float + database_path: Path + log_path: Path + env_file_path: Path + + @property + def rest_base_url(self) -> str: + return "https://api-testnet.bybit.com" if self.bybit_testnet else "https://api.bybit.com" + + @property + def websocket_url(self) -> str: + return ( + "wss://stream-testnet.bybit.com/v5/public/spot" + if self.bybit_testnet + else "wss://stream.bybit.com/v5/public/spot" + ) + + @property + def live_ready(self) -> bool: + return ( + self.trading_mode == "live" + and self.enable_live_trading + and self.live_trading_confirm == "I_ACCEPT_REAL_RISK" + and bool(self.bybit_api_key) + and bool(self.bybit_api_secret) + ) + + @property + def effective_loop_interval_seconds(self) -> float: + interval = ( + self.fast_loop_interval_seconds + if self.fast_trading_enabled + else float(self.loop_interval_seconds) + ) + return max(0.25, interval) + + @property + def effective_entry_cooldown_seconds(self) -> int: + return ( + max(0, self.fast_entry_cooldown_seconds) + if self.fast_trading_enabled + else max(0, self.entry_cooldown_seconds) + ) + + +def load_settings(env_file: str | Path | None = None) -> Settings: + root = Path.cwd() + env_path = Path(env_file) if env_file else root / ".env" + _load_dotenv(env_path) + mode = os.getenv("TRADING_MODE", "paper").strip().lower() + if mode not in {"paper", "live"}: + raise ValueError("TRADING_MODE must be paper or live") + settings = Settings( + trading_mode=mode, + host=os.getenv("HOST", "127.0.0.1"), + port=_int_env("PORT", 8787), + bybit_testnet=_bool_env("BYBIT_TESTNET", False), + bybit_api_key=os.getenv("BYBIT_API_KEY", ""), + bybit_api_secret=os.getenv("BYBIT_API_SECRET", ""), + starting_balance_usdt=_float_env("STARTING_BALANCE_USDT", 100.0), + auto_select_symbols=_bool_env("AUTO_SELECT_SYMBOLS", True), + top_symbols_count=_int_env("TOP_SYMBOLS_COUNT", 6), + symbols=_symbols_env("SYMBOLS"), + base_interval=os.getenv("BASE_INTERVAL", "1"), + kline_limit=_int_env("KLINE_LIMIT", 240), + loop_interval_seconds=_int_env("LOOP_INTERVAL_SECONDS", 5), + fast_trading_enabled=_bool_env("FAST_TRADING_ENABLED", False), + fast_loop_interval_seconds=_float_env("FAST_LOOP_INTERVAL_SECONDS", 1.0), + fast_entry_cooldown_seconds=_int_env("FAST_ENTRY_COOLDOWN_SECONDS", 20), + max_entries_per_minute=_int_env("MAX_ENTRIES_PER_MINUTE", 12), + websocket_enabled=_bool_env("WEBSOCKET_ENABLED", True), + min_signal_confidence=_float_env("MIN_SIGNAL_CONFIDENCE", 0.64), + max_spread_percent=_float_env("MAX_SPREAD_PERCENT", 0.18), + min_24h_turnover_usdt=_float_env("MIN_24H_TURNOVER_USDT", 1000000.0), + pattern_analysis_enabled=_bool_env("PATTERN_ANALYSIS_ENABLED", True), + pattern_score_weight=_float_env("PATTERN_SCORE_WEIGHT", 0.18), + learning_enabled=_bool_env("LEARNING_ENABLED", True), + learning_lookback_trades=_int_env("LEARNING_LOOKBACK_TRADES", 120), + learning_min_samples=_int_env("LEARNING_MIN_SAMPLES", 3), + learning_max_adjustment=_float_env("LEARNING_MAX_ADJUSTMENT", 0.12), + llm_advisor_enabled=_bool_env("LLM_ADVISOR_ENABLED", False), + ollama_base_url=os.getenv("OLLAMA_BASE_URL", "http://192.168.0.210:11434").rstrip("/"), + ollama_model=os.getenv("OLLAMA_MODEL", "gemma4:e4b"), + llm_advisor_min_interval_seconds=_int_env("LLM_ADVISOR_MIN_INTERVAL_SECONDS", 180), + llm_advisor_timeout_seconds=_int_env("LLM_ADVISOR_TIMEOUT_SECONDS", 45), + llm_advisor_max_adjustment=_float_env("LLM_ADVISOR_MAX_ADJUSTMENT", 0.06), + min_position_usdt=_float_env("MIN_POSITION_USDT", 1.0), + max_position_usdt=_float_env("MAX_POSITION_USDT", 20.0), + max_symbol_exposure_usdt=_float_env("MAX_SYMBOL_EXPOSURE_USDT", 20.0), + max_total_exposure_usdt=_float_env("MAX_TOTAL_EXPOSURE_USDT", 80.0), + max_open_positions=_int_env("MAX_OPEN_POSITIONS", 6), + max_positions_per_symbol=_int_env("MAX_POSITIONS_PER_SYMBOL", 1), + grid_trading_enabled=_bool_env("GRID_TRADING_ENABLED", True), + grid_entry_confidence=_float_env("GRID_ENTRY_CONFIDENCE", 0.58), + grid_buy_zone=_float_env("GRID_BUY_ZONE", 0.45), + grid_max_position_usdt=_float_env("GRID_MAX_POSITION_USDT", 8.0), + rebound_trading_enabled=_bool_env("REBOUND_TRADING_ENABLED", True), + rebound_entry_confidence=_float_env("REBOUND_ENTRY_CONFIDENCE", 0.58), + rebound_min_probability=_float_env("REBOUND_MIN_PROBABILITY", 0.58), + rebound_max_position_usdt=_float_env("REBOUND_MAX_POSITION_USDT", 6.0), + time_series_forecast_enabled=_bool_env("TIME_SERIES_FORECAST_ENABLED", True), + time_series_min_candles=_int_env("TIME_SERIES_MIN_CANDLES", 120), + time_series_validation_window=_int_env("TIME_SERIES_VALIDATION_WINDOW", 30), + time_series_forecast_horizon=_int_env("TIME_SERIES_FORECAST_HORIZON", 3), + time_series_ewma_lambda=_float_env("TIME_SERIES_EWMA_LAMBDA", 0.94), + time_series_min_edge_percent=_float_env("TIME_SERIES_MIN_EDGE_PERCENT", 0.04), + time_series_max_adjustment=_float_env("TIME_SERIES_MAX_ADJUSTMENT", 0.08), + time_series_lstm_enabled=_bool_env("TIME_SERIES_LSTM_ENABLED", True), + time_series_lstm_lookback=_int_env("TIME_SERIES_LSTM_LOOKBACK", 32), + time_series_lstm_units=_int_env("TIME_SERIES_LSTM_UNITS", 6), + time_series_lstm_ridge=_float_env("TIME_SERIES_LSTM_RIDGE", 0.0001), + time_series_lstm_model_path=Path(os.getenv("TIME_SERIES_LSTM_MODEL_PATH", "runtime/lstm_forecaster.json")), + stop_loss_percent=_float_env("STOP_LOSS_PERCENT", 0.02), + take_profit_percent=_float_env("TAKE_PROFIT_PERCENT", 0.035), + trailing_stop_percent=_float_env("TRAILING_STOP_PERCENT", 0.015), + min_hold_seconds=_int_env("MIN_HOLD_SECONDS", 180), + entry_cooldown_seconds=_int_env("ENTRY_COOLDOWN_SECONDS", 180), + max_daily_drawdown_usdt=_float_env("MAX_DAILY_DRAWDOWN_USDT", 6.0), + min_cash_reserve_usdt=_float_env("MIN_CASH_RESERVE_USDT", 5.0), + taker_fee_rate=_float_env("TAKER_FEE_RATE", 0.001), + slippage_rate=_float_env("SLIPPAGE_RATE", 0.0003), + enable_live_trading=_bool_env("ENABLE_LIVE_TRADING", False), + live_trading_confirm=os.getenv("LIVE_TRADING_CONFIRM", ""), + live_order_max_usdt=_float_env("LIVE_ORDER_MAX_USDT", 10.0), + database_path=Path(os.getenv("DATABASE_PATH", "runtime/tradebot.sqlite3")), + log_path=Path(os.getenv("LOG_PATH", "runtime/tradebot.log")), + env_file_path=env_path, + ) + if settings.trading_mode == "live" and not settings.live_ready: + raise ValueError( + "Live mode is locked. Set ENABLE_LIVE_TRADING=true, " + "LIVE_TRADING_CONFIRM=I_ACCEPT_REAL_RISK and Bybit keys." + ) + return settings + + +def update_env_value(path: Path, key: str, value: str) -> None: + lines = path.read_text(encoding="utf-8").splitlines() if path.exists() else [] + output: list[str] = [] + replaced = False + for line in lines: + stripped = line.strip() + if stripped and not stripped.startswith("#") and "=" in stripped: + current_key = stripped.split("=", 1)[0].strip() + if current_key == key: + output.append(f"{key}={value}") + replaced = True + continue + output.append(line) + if not replaced: + output.append(f"{key}={value}") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("\n".join(output).rstrip() + "\n", encoding="utf-8") diff --git a/crypto_spot_bot/dashboard.py b/crypto_spot_bot/dashboard.py new file mode 100644 index 0000000..c390d62 --- /dev/null +++ b/crypto_spot_bot/dashboard.py @@ -0,0 +1,1000 @@ +from __future__ import annotations + +import json +from contextlib import asynccontextmanager +from typing import Any + +from fastapi import FastAPI, Response +from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse + +from crypto_spot_bot.bot import CryptoSpotBot +from crypto_spot_bot.bybit import BybitClient +from crypto_spot_bot.config import Settings, load_settings, update_env_value +from crypto_spot_bot.execution import LiveBroker, PaperBroker +from crypto_spot_bot.learning import TradeLearner +from crypto_spot_bot.market_data import MarketData +from crypto_spot_bot.patterns import PatternAnalyzer +from crypto_spot_bot.storage import Storage +from crypto_spot_bot.strategy import SpotStrategy +from crypto_spot_bot.time_series import TimeSeriesForecaster + + +def create_app(settings: Settings | None = None) -> FastAPI: + settings = settings or load_settings() + storage = Storage(settings.database_path) + runtime_fast_trading = storage.get_runtime("fast_trading_enabled", None) + if isinstance(runtime_fast_trading, bool): + settings.fast_trading_enabled = runtime_fast_trading + client = BybitClient(settings) + market = MarketData(settings, client, storage) + broker: PaperBroker | LiveBroker + if settings.trading_mode == "live": + broker = LiveBroker(settings, storage, client) + else: + broker = PaperBroker(settings, storage) + strategy = SpotStrategy(settings) + pattern_analyzer = PatternAnalyzer() + learner = TradeLearner(settings, storage) + forecaster = TimeSeriesForecaster(settings) + bot = CryptoSpotBot(settings, storage, market, broker, strategy, pattern_analyzer, learner, forecaster) + + @asynccontextmanager + async def lifespan(_: FastAPI): + await bot.start() + try: + yield + finally: + await bot.stop() + + app = FastAPI(title="Крипто спот-бот", lifespan=lifespan) + app.state.settings = settings + app.state.storage = storage + app.state.bot = bot + app.state.market = market + + @app.get("/", response_class=HTMLResponse) + async def index() -> str: + return HTML + + @app.get("/api/health") + async def health() -> dict[str, Any]: + return {"ok": True, "running": bot.running, "mode": settings.trading_mode} + + @app.get("/api/status") + async def status() -> dict[str, Any]: + return { + "status": bot.status().as_dict(), + "account": bot.account_snapshot(), + "positions": bot.positions_snapshot(), + "learning": bot.learning_snapshot(), + "latest_equity": storage.latest_equity(), + } + + @app.get("/api/markets") + async def markets() -> dict[str, Any]: + return market.snapshot() + + @app.get("/api/trades") + async def trades(limit: int = 80) -> dict[str, Any]: + return {"items": storage.recent_trades(_limit(limit))} + + @app.get("/api/signals") + async def signals(limit: int = 120) -> dict[str, Any]: + return {"items": storage.recent_signals(_limit(limit))} + + @app.get("/api/events") + async def events(limit: int = 120) -> dict[str, Any]: + return {"items": storage.recent_events(_limit(limit))} + + @app.get("/api/config") + async def config() -> dict[str, Any]: + return _safe_config(settings) + + @app.post("/api/config/fast-trading") + async def set_fast_trading(payload: dict[str, Any]) -> dict[str, Any]: + enabled = _enabled_from_payload(payload) + env_persisted = _apply_fast_trading(settings, storage, enabled) + response = _safe_config(settings) + response["env_persisted"] = env_persisted + return response + + @app.post("/api/control/start") + async def start() -> dict[str, Any]: + await bot.start() + return bot.status().as_dict() + + @app.post("/api/control/stop") + async def stop() -> dict[str, Any]: + await bot.stop() + return bot.status().as_dict() + + @app.get("/metrics") + async def metrics() -> Response: + account = bot.account_snapshot() + lines = [ + "# HELP tradebot_equity_usdt Current account equity.", + "# TYPE tradebot_equity_usdt gauge", + f"tradebot_equity_usdt {account['equity']:.8f}", + "# HELP tradebot_cash_usdt Current free USDT cash.", + "# TYPE tradebot_cash_usdt gauge", + f"tradebot_cash_usdt {account['cash']:.8f}", + "# HELP tradebot_open_positions Open positions count.", + "# TYPE tradebot_open_positions gauge", + f"tradebot_open_positions {len(bot.positions_snapshot())}", + "# HELP tradebot_websocket_connected Bybit WebSocket connection status.", + "# TYPE tradebot_websocket_connected gauge", + f"tradebot_websocket_connected {1 if market.ws_connected else 0}", + "# HELP tradebot_fast_trading_enabled Fast trading mode status.", + "# TYPE tradebot_fast_trading_enabled gauge", + f"tradebot_fast_trading_enabled {1 if settings.fast_trading_enabled else 0}", + "# HELP tradebot_loop_interval_seconds Effective bot decision loop interval.", + "# TYPE tradebot_loop_interval_seconds gauge", + f"tradebot_loop_interval_seconds {settings.effective_loop_interval_seconds:.4f}", + ] + return PlainTextResponse("\n".join(lines) + "\n") + + @app.exception_handler(Exception) + async def error_handler(_, exc: Exception) -> JSONResponse: + storage.event(f"API error: {exc}", "ERROR") + return JSONResponse({"error": str(exc)}, status_code=500) + + return app + + +def _limit(value: int) -> int: + return max(1, min(int(value), 500)) + + +def _enabled_from_payload(payload: dict[str, Any]) -> bool: + value = payload.get("enabled") + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.strip().lower() in {"1", "true", "yes", "y", "on", "вкл", "включено"} + return bool(value) + + +def _apply_fast_trading(settings: Settings, storage: Storage, enabled: bool) -> bool: + settings.fast_trading_enabled = enabled + storage.set_runtime("fast_trading_enabled", enabled) + env_persisted = True + try: + update_env_value(settings.env_file_path, "FAST_TRADING_ENABLED", "true" if enabled else "false") + except OSError as exc: + env_persisted = False + storage.event(f"Быстрая торговля изменена только в runtime, .env не записан: {exc}", "WARN") + state = "включена" if enabled else "выключена" + storage.event(f"Быстрая торговля {state}") + return env_persisted + + +def _safe_config(settings: Settings) -> dict[str, Any]: + return { + "trading_mode": settings.trading_mode, + "bybit_testnet": settings.bybit_testnet, + "starting_balance_usdt": settings.starting_balance_usdt, + "auto_select_symbols": settings.auto_select_symbols, + "top_symbols_count": settings.top_symbols_count, + "symbols": settings.symbols, + "base_interval": settings.base_interval, + "kline_limit": settings.kline_limit, + "loop_interval_seconds": settings.loop_interval_seconds, + "fast_trading_enabled": settings.fast_trading_enabled, + "fast_loop_interval_seconds": settings.fast_loop_interval_seconds, + "effective_loop_interval_seconds": settings.effective_loop_interval_seconds, + "fast_entry_cooldown_seconds": settings.fast_entry_cooldown_seconds, + "effective_entry_cooldown_seconds": settings.effective_entry_cooldown_seconds, + "max_entries_per_minute": settings.max_entries_per_minute, + "websocket_enabled": settings.websocket_enabled, + "min_signal_confidence": settings.min_signal_confidence, + "max_spread_percent": settings.max_spread_percent, + "min_24h_turnover_usdt": settings.min_24h_turnover_usdt, + "pattern_analysis_enabled": settings.pattern_analysis_enabled, + "pattern_score_weight": settings.pattern_score_weight, + "learning_enabled": settings.learning_enabled, + "learning_lookback_trades": settings.learning_lookback_trades, + "learning_min_samples": settings.learning_min_samples, + "learning_max_adjustment": settings.learning_max_adjustment, + "min_position_usdt": settings.min_position_usdt, + "max_position_usdt": settings.max_position_usdt, + "max_symbol_exposure_usdt": settings.max_symbol_exposure_usdt, + "max_total_exposure_usdt": settings.max_total_exposure_usdt, + "max_open_positions": settings.max_open_positions, + "max_positions_per_symbol": settings.max_positions_per_symbol, + "grid_trading_enabled": settings.grid_trading_enabled, + "grid_entry_confidence": settings.grid_entry_confidence, + "grid_buy_zone": settings.grid_buy_zone, + "grid_max_position_usdt": settings.grid_max_position_usdt, + "rebound_trading_enabled": settings.rebound_trading_enabled, + "rebound_entry_confidence": settings.rebound_entry_confidence, + "rebound_min_probability": settings.rebound_min_probability, + "rebound_max_position_usdt": settings.rebound_max_position_usdt, + "time_series_forecast_enabled": settings.time_series_forecast_enabled, + "time_series_min_candles": settings.time_series_min_candles, + "time_series_validation_window": settings.time_series_validation_window, + "time_series_forecast_horizon": settings.time_series_forecast_horizon, + "time_series_ewma_lambda": settings.time_series_ewma_lambda, + "time_series_min_edge_percent": settings.time_series_min_edge_percent, + "time_series_max_adjustment": settings.time_series_max_adjustment, + "time_series_lstm_enabled": settings.time_series_lstm_enabled, + "time_series_lstm_lookback": settings.time_series_lstm_lookback, + "time_series_lstm_units": settings.time_series_lstm_units, + "time_series_lstm_ridge": settings.time_series_lstm_ridge, + "time_series_lstm_model_path": str(settings.time_series_lstm_model_path), + "stop_loss_percent": settings.stop_loss_percent, + "take_profit_percent": settings.take_profit_percent, + "trailing_stop_percent": settings.trailing_stop_percent, + "min_hold_seconds": settings.min_hold_seconds, + "entry_cooldown_seconds": settings.entry_cooldown_seconds, + "max_daily_drawdown_usdt": settings.max_daily_drawdown_usdt, + "min_cash_reserve_usdt": settings.min_cash_reserve_usdt, + "taker_fee_rate": settings.taker_fee_rate, + "slippage_rate": settings.slippage_rate, + "live_ready": settings.live_ready, + "live_order_max_usdt": settings.live_order_max_usdt, + } + + +HTML = r""" + + + + + + Крипто спот-бот + + + +
+
+

Крипто спот-бот

+
Загрузка состояния...
+
+
+ Демо + Реальная торговля заблокирована + + Обычный режим + Поток данных + + +
+
+
+
+
Баланс
-
+
Свободно
-
+
В позициях
-
+
Прибыль/убыток
-
+
Позиции
-
+
Просадка
-
+
+
+
+
+
+

Открытые позиции

+
ПараКол-воВходЦенаПрибыль/убытокСтопЦель
+
+
+

Сделки

+
ВремяПараСторонаКол-воВходВыходИтогПричина
+
+
+

Сигналы стратегии

+
ВремяПараДействиеРежимРазмерУверенностьБазаШаблонОбучениеПрогнозОтскокИтогПричина
+
+
+ +
+
+ + + +""" diff --git a/crypto_spot_bot/execution.py b/crypto_spot_bot/execution.py new file mode 100644 index 0000000..223f0af --- /dev/null +++ b/crypto_spot_bot/execution.py @@ -0,0 +1,354 @@ +from __future__ import annotations + +from collections import deque +from datetime import timedelta +from decimal import Decimal, ROUND_DOWN +from typing import Iterable +from uuid import uuid4 + +from crypto_spot_bot.bybit import BybitClient, Instrument +from crypto_spot_bot.config import Settings +from crypto_spot_bot.models import Position, Signal, Ticker, Trade, utc_now +from crypto_spot_bot.storage import Storage + + +class BrokerError(RuntimeError): + pass + + +def _round_step(value: float, step: float) -> float: + if step <= 0: + return value + value_decimal = Decimal(str(value)) + step_decimal = Decimal(str(step)) + rounded = (value_decimal / step_decimal).to_integral_value(rounding=ROUND_DOWN) + return float(rounded * step_decimal) + + +class PaperBroker: + def __init__(self, settings: Settings, storage: Storage): + self.settings = settings + self.storage = storage + self.positions = storage.open_positions() + self.cash = float(storage.get_runtime("paper_cash", settings.starting_balance_usdt)) + self.peak_equity = float(storage.get_runtime("peak_equity", settings.starting_balance_usdt)) + self._entry_timestamps = deque() + + def open_positions(self) -> list[Position]: + return list(self.positions) + + def positions_for_symbol(self, symbol: str) -> list[Position]: + return [position for position in self.positions if position.symbol == symbol] + + def exposure(self) -> float: + return sum(position.notional_usdt for position in self.positions) + + def symbol_exposure(self, symbol: str) -> float: + return sum(position.notional_usdt for position in self.positions_for_symbol(symbol)) + + def equity(self, prices: dict[str, float]) -> float: + value = self.cash + for position in self.positions: + value += position.mark_price(prices.get(position.symbol, position.entry_price)) + return value + + def mark_equity(self, prices: dict[str, float]) -> dict[str, float]: + state = self.account_state(prices) + equity = state["equity"] + self.peak_equity = max(self.peak_equity, equity) + state["drawdown"] = max(0.0, self.peak_equity - equity) + self.storage.set_runtime("paper_cash", self.cash) + self.storage.set_runtime("peak_equity", self.peak_equity) + self.storage.insert_equity(equity, self.cash, self.exposure(), state["drawdown"]) + return state + + def account_state(self, prices: dict[str, float]) -> dict[str, float]: + equity = self.equity(prices) + return { + "equity": equity, + "cash": self.cash, + "exposure": self.exposure(), + "drawdown": max(0.0, self.peak_equity - equity), + } + + def update_highs(self, tickers: dict[str, Ticker]) -> None: + for position in self.positions: + ticker = tickers.get(position.symbol) + if not ticker: + continue + price = ticker.last_price + if price > position.highest_price: + position.highest_price = price + if position.id is not None: + self.storage.update_position_highest(position.id, price) + + def can_open( + self, + symbol: str, + prices: dict[str, float], + requested_notional: float | None = None, + ) -> tuple[bool, str]: + if not self._entry_rate_limit_allows(): + return False, "достигнут лимит новых входов в минуту" + if len(self.positions) >= self.settings.max_open_positions: + return False, "достигнут общий лимит открытых позиций" + dynamic_pair_limit = max( + self.settings.max_positions_per_symbol, + int(self.settings.max_symbol_exposure_usdt // max(self.settings.min_position_usdt, 0.01)), + ) + if len(self.positions_for_symbol(symbol)) >= dynamic_pair_limit: + return False, "достигнут лимит позиций по паре" + requested = requested_notional if requested_notional is not None else self.settings.min_position_usdt + symbol_room = max(0.0, self.settings.max_symbol_exposure_usdt - self.symbol_exposure(symbol)) + if symbol_room < min(requested, self.settings.min_position_usdt): + return False, "достигнут лимит экспозиции по паре" + if self.cash <= self.settings.min_cash_reserve_usdt: + return False, "недостаточно свободного USDT после резерва" + if self.exposure() >= self.settings.max_total_exposure_usdt: + return False, "достигнут лимит общей экспозиции" + equity_state = self.mark_equity(prices) + if equity_state["drawdown"] >= self.settings.max_daily_drawdown_usdt: + return False, "достигнут лимит просадки" + return True, "ok" + + def buy( + self, + signal: Signal, + ticker: Ticker, + instrument: Instrument | None, + prices: dict[str, float], + ) -> Position | None: + requested_notional = self._signal_notional(signal) + allowed, reason = self.can_open(ticker.symbol, prices, requested_notional) + if not allowed: + self.storage.event(f"{ticker.symbol}: покупка пропущена, {reason}", "WARN") + return None + return self._record_buy(signal, ticker, instrument, "демо-покупка") + + def _record_buy( + self, + signal: Signal, + ticker: Ticker, + instrument: Instrument | None, + event_label: str, + ) -> Position | None: + + fill_price = self._buy_price(ticker) + notional = self._entry_budget(signal, ticker) + if notional < self.settings.min_position_usdt: + self.storage.event(f"{ticker.symbol}: покупка пропущена, adaptive-лимит экспозиции исчерпан", "WARN") + return None + notional = notional / (1 + self.settings.taker_fee_rate) + qty = _round_step(notional / fill_price, instrument.qty_step if instrument else 0) + if instrument and qty < instrument.min_order_qty: + self.storage.event(f"{ticker.symbol}: количество ниже minOrderQty Bybit", "WARN") + return None + executed_notional = qty * fill_price + if instrument and executed_notional < instrument.min_notional_value: + self.storage.event(f"{ticker.symbol}: сумма ниже minNotionalValue Bybit", "WARN") + return None + fee = executed_notional * self.settings.taker_fee_rate + if executed_notional + fee > self.cash: + self.storage.event(f"{ticker.symbol}: недостаточно cash для комиссии", "WARN") + return None + + stop_loss_percent = self._signal_percent(signal, "stop_loss_percent", self.settings.stop_loss_percent, 0.003, 0.08) + take_profit_percent = self._signal_percent( + signal, "take_profit_percent", self.settings.take_profit_percent, 0.003, 0.20 + ) + position = Position( + id=None, + symbol=ticker.symbol, + qty=qty, + entry_price=fill_price, + notional_usdt=executed_notional, + entry_fee_usdt=fee, + stop_loss=fill_price * (1 - stop_loss_percent), + take_profit=fill_price * (1 + take_profit_percent), + highest_price=fill_price, + entry_reason=signal.reason, + entry_confidence=signal.confidence, + entry_pattern=str(signal.diagnostics.get("pattern", {}).get("label", "")), + ) + position.id = self.storage.insert_position(position) + self.positions.append(position) + self._record_entry_timestamp() + self.cash -= executed_notional + fee + self.storage.set_runtime("paper_cash", self.cash) + self.storage.insert_trade( + Trade( + id=None, + symbol=ticker.symbol, + side="BUY", + qty=qty, + entry_price=fill_price, + fee_usdt=fee, + net_pnl=-fee, + reason=signal.reason, + entry_pattern=position.entry_pattern, + entry_confidence=position.entry_confidence, + opened_at=position.opened_at, + ) + ) + self.storage.event( + f"{ticker.symbol}: {event_label} кол-во={qty:.8f} цена={fill_price:.8f} сумма={executed_notional:.2f} уверенность={signal.confidence:.2f}" + ) + return position + + def sell(self, position: Position, ticker: Ticker, reason: str) -> Trade: + return self._record_sell(position, ticker, reason, "демо-продажа") + + def _record_sell( + self, + position: Position, + ticker: Ticker, + reason: str, + event_label: str, + ) -> Trade: + fill_price = self._sell_price(ticker) + exit_notional = position.qty * fill_price + exit_fee = exit_notional * self.settings.taker_fee_rate + gross_pnl = (fill_price - position.entry_price) * position.qty + net_pnl = gross_pnl - position.entry_fee_usdt - exit_fee + self.cash += exit_notional - exit_fee + if position.id is not None: + self.storage.close_position(position.id) + self.positions = [item for item in self.positions if item.id != position.id] + self.storage.set_runtime("paper_cash", self.cash) + trade = Trade( + id=None, + symbol=position.symbol, + side="SELL", + qty=position.qty, + entry_price=position.entry_price, + exit_price=fill_price, + gross_pnl=gross_pnl, + fee_usdt=position.entry_fee_usdt + exit_fee, + net_pnl=net_pnl, + reason=reason, + entry_pattern=position.entry_pattern, + entry_confidence=position.entry_confidence, + opened_at=position.opened_at, + closed_at=utc_now(), + ) + trade.id = self.storage.insert_trade(trade) + self.storage.event( + f"{position.symbol}: {event_label} кол-во={position.qty:.8f} цена={fill_price:.8f} итог={net_pnl:.4f} причина={reason}" + ) + return trade + + def _buy_price(self, ticker: Ticker) -> float: + base = ticker.ask if ticker.ask > 0 else ticker.last_price + return base * (1 + self.settings.slippage_rate) + + def _sell_price(self, ticker: Ticker) -> float: + base = ticker.bid if ticker.bid > 0 else ticker.last_price + return base * (1 - self.settings.slippage_rate) + + def _signal_notional(self, signal: Signal) -> float: + raw = signal.diagnostics.get("position_notional_usdt", self.settings.max_position_usdt) + try: + value = float(raw) + except (TypeError, ValueError): + value = self.settings.max_position_usdt + low = max(0.0, self.settings.min_position_usdt) + high = max(low, self.settings.max_position_usdt) + return max(low, min(high, value)) + + def _signal_percent(self, signal: Signal, key: str, default: float, low: float, high: float) -> float: + rules = signal.diagnostics.get("adaptive_rules") or {} + raw = signal.diagnostics.get(key, rules.get(key, default) if isinstance(rules, dict) else default) + try: + value = float(raw) + except (TypeError, ValueError): + value = default + return max(low, min(high, value)) + + def _entry_budget(self, signal: Signal, ticker: Ticker, extra_cap: float | None = None) -> float: + available = max(0.0, self.cash - self.settings.min_cash_reserve_usdt) + rules = signal.diagnostics.get("adaptive_rules") or {} + target_total = self._adaptive_cap(rules, "target_total_exposure_usdt", self.settings.max_total_exposure_usdt) + target_symbol = self._adaptive_cap(rules, "target_symbol_exposure_usdt", self.settings.max_symbol_exposure_usdt) + exposure_room = max(0.0, target_total - self.exposure()) + symbol_room = max(0.0, target_symbol - self.symbol_exposure(ticker.symbol)) + caps = [self._signal_notional(signal), available, exposure_room, symbol_room] + if extra_cap is not None: + caps.append(max(0.0, extra_cap)) + return max(0.0, min(caps)) + + def _adaptive_cap(self, rules: object, key: str, default: float) -> float: + if not isinstance(rules, dict): + return default + try: + value = float(rules.get(key, default)) + except (TypeError, ValueError): + value = default + return max(0.0, min(default, value)) + + def _entry_rate_limit_allows(self) -> bool: + limit = self.settings.max_entries_per_minute + if limit <= 0: + return True + now = utc_now() + cutoff = now - timedelta(seconds=60) + while self._entry_timestamps and self._entry_timestamps[0] < cutoff: + self._entry_timestamps.popleft() + return len(self._entry_timestamps) < limit + + def _record_entry_timestamp(self) -> None: + if self.settings.max_entries_per_minute <= 0: + return + self._entry_timestamps.append(utc_now()) + + +class LiveBroker(PaperBroker): + def __init__(self, settings: Settings, storage: Storage, client: BybitClient): + super().__init__(settings, storage) + if not settings.live_ready: + raise BrokerError("Live mode is not unlocked by settings") + self.client = client + + def buy( + self, + signal: Signal, + ticker: Ticker, + instrument: Instrument | None, + prices: dict[str, float], + ) -> Position | None: + requested_notional = min(self._signal_notional(signal), self.settings.live_order_max_usdt) + allowed, reason = self.can_open(ticker.symbol, prices, requested_notional) + if not allowed: + self.storage.event(f"{ticker.symbol}: live BUY пропущен, {reason}", "WARN") + return None + budget = self._entry_budget(signal, ticker, self.settings.live_order_max_usdt) + if budget < self.settings.min_position_usdt: + self.storage.event(f"{ticker.symbol}: live BUY skipped, adjusted budget below minimum", "WARN") + return None + signal.diagnostics["position_notional_usdt"] = budget + notional = budget / (1 + self.settings.taker_fee_rate) + response = self.client.place_spot_market_order( + symbol=ticker.symbol, + side="Buy", + qty=notional, + market_unit="quoteCoin", + order_link_id=f"tb-buy-{uuid4().hex[:18]}", + ) + self.storage.event(f"{ticker.symbol}: реальная покупка отправлена orderId={response.get('orderId')}") + return self._record_buy(signal, ticker, instrument, "реальная покупка, локальная запись") + + def sell(self, position: Position, ticker: Ticker, reason: str) -> Trade: + response = self.client.place_spot_market_order( + symbol=position.symbol, + side="Sell", + qty=position.qty, + market_unit="baseCoin", + order_link_id=f"tb-sell-{uuid4().hex[:18]}", + ) + self.storage.event( + f"{position.symbol}: реальная продажа отправлена orderId={response.get('orderId')} причина={reason}" + ) + return self._record_sell(position, ticker, reason, "реальная продажа, локальная запись") + + +def prices_from_tickers(tickers: Iterable[Ticker]) -> dict[str, float]: + return {ticker.symbol: ticker.last_price for ticker in tickers} diff --git a/crypto_spot_bot/indicators.py b/crypto_spot_bot/indicators.py new file mode 100644 index 0000000..083592a --- /dev/null +++ b/crypto_spot_bot/indicators.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +from statistics import fmean + +from crypto_spot_bot.models import Candle + + +def add_indicators(candles: list[Candle]) -> list[Candle]: + closes = [c.close for c in candles] + highs = [c.high for c in candles] + lows = [c.low for c in candles] + volumes = [c.volume for c in candles] + ema20 = _ema(closes, 20) + ema50 = _ema(closes, 50) + ema200 = _ema(closes, 200) + rsi14 = _rsi(closes, 14) + atr14 = _atr(highs, lows, closes, 14) + volume_ma20 = _sma(volumes, 20) + for index, candle in enumerate(candles): + candle.ema_20 = ema20[index] + candle.ema_50 = ema50[index] + candle.ema_200 = ema200[index] + candle.rsi_14 = rsi14[index] + candle.atr_14 = atr14[index] + candle.volume_ma_20 = volume_ma20[index] + return candles + + +def _ema(values: list[float], period: int) -> list[float | None]: + if not values: + return [] + result: list[float | None] = [None] * len(values) + if len(values) < period: + return result + seed = fmean(values[:period]) + result[period - 1] = seed + multiplier = 2 / (period + 1) + previous = seed + for index in range(period, len(values)): + previous = (values[index] - previous) * multiplier + previous + result[index] = previous + return result + + +def _sma(values: list[float], period: int) -> list[float | None]: + result: list[float | None] = [] + for index in range(len(values)): + if index + 1 < period: + result.append(None) + else: + result.append(fmean(values[index + 1 - period : index + 1])) + return result + + +def _rsi(closes: list[float], period: int) -> list[float | None]: + result: list[float | None] = [None] * len(closes) + if len(closes) <= period: + return result + gains: list[float] = [] + losses: list[float] = [] + for index in range(1, period + 1): + delta = closes[index] - closes[index - 1] + gains.append(max(delta, 0.0)) + losses.append(abs(min(delta, 0.0))) + avg_gain = fmean(gains) + avg_loss = fmean(losses) + result[period] = _rsi_value(avg_gain, avg_loss) + for index in range(period + 1, len(closes)): + delta = closes[index] - closes[index - 1] + gain = max(delta, 0.0) + loss = abs(min(delta, 0.0)) + avg_gain = ((avg_gain * (period - 1)) + gain) / period + avg_loss = ((avg_loss * (period - 1)) + loss) / period + result[index] = _rsi_value(avg_gain, avg_loss) + return result + + +def _rsi_value(avg_gain: float, avg_loss: float) -> float: + if avg_loss == 0: + return 100.0 + rs = avg_gain / avg_loss + return 100 - (100 / (1 + rs)) + + +def _atr(highs: list[float], lows: list[float], closes: list[float], period: int) -> list[float | None]: + result: list[float | None] = [None] * len(closes) + true_ranges: list[float] = [] + for index in range(len(closes)): + if index == 0: + true_ranges.append(highs[index] - lows[index]) + else: + true_ranges.append( + max( + highs[index] - lows[index], + abs(highs[index] - closes[index - 1]), + abs(lows[index] - closes[index - 1]), + ) + ) + if len(true_ranges) < period: + return result + atr = fmean(true_ranges[:period]) + result[period - 1] = atr + for index in range(period, len(true_ranges)): + atr = ((atr * (period - 1)) + true_ranges[index]) / period + result[index] = atr + return result diff --git a/crypto_spot_bot/learning.py b/crypto_spot_bot/learning.py new file mode 100644 index 0000000..d91223c --- /dev/null +++ b/crypto_spot_bot/learning.py @@ -0,0 +1,407 @@ +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +from typing import Any + +from crypto_spot_bot.config import Settings +from crypto_spot_bot.storage import Storage + + +@dataclass(slots=True) +class LearningAdjustment: + symbol: str + pattern: str + sample_size: int + net_pnl: float + win_rate: float + confidence_adjustment: float + reason: str + adaptive_rules: dict[str, Any] = field(default_factory=dict) + + def as_dict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass(slots=True) +class LearningState: + enabled: bool + sample_size: int + net_pnl: float + win_rate: float + symbol_stats: dict[str, dict[str, Any]] = field(default_factory=dict) + pattern_stats: dict[str, dict[str, Any]] = field(default_factory=dict) + adaptive_rules: dict[str, Any] = field(default_factory=dict) + + def as_dict(self) -> dict[str, Any]: + return asdict(self) + + +class TradeLearner: + def __init__(self, settings: Settings, storage: Storage): + self.settings = settings + self.storage = storage + self.state = LearningState( + enabled=settings.learning_enabled, + sample_size=0, + net_pnl=0.0, + win_rate=0.0, + adaptive_rules=_neutral_rules(settings, "мало закрытых сделок для изменения правил"), + ) + + def refresh(self) -> LearningState: + if not self.settings.learning_enabled: + self.state = LearningState( + enabled=False, + sample_size=0, + net_pnl=0.0, + win_rate=0.0, + adaptive_rules={"enabled": False, "reason": "обучение выключено"}, + ) + self.storage.set_runtime("learning_state", self.state.as_dict()) + return self.state + + trades = self.storage.closed_trades(self.settings.learning_lookback_trades) + total_net = sum(float(trade.get("net_pnl") or 0.0) for trade in trades) + wins = sum(1 for trade in trades if float(trade.get("net_pnl") or 0.0) > 0) + symbol_stats = _group_stats(trades, "symbol") + pattern_stats = _group_stats(trades, "entry_pattern") + reason_stats = _group_stats(trades, "reason") + adaptive_rules = _build_adaptive_rules( + trades=trades, + settings=self.settings, + total_net=total_net, + wins=wins, + symbol_stats=symbol_stats, + pattern_stats=pattern_stats, + reason_stats=reason_stats, + ) + self.state = LearningState( + enabled=True, + sample_size=len(trades), + net_pnl=round(total_net, 6), + win_rate=round(wins / len(trades), 4) if trades else 0.0, + symbol_stats=symbol_stats, + pattern_stats=pattern_stats, + adaptive_rules=adaptive_rules, + ) + self.storage.set_runtime("learning_state", self.state.as_dict()) + return self.state + + def adjustment_for(self, symbol: str, pattern: str) -> LearningAdjustment: + if not self.settings.learning_enabled: + return LearningAdjustment(symbol, pattern, 0, 0.0, 0.0, 0.0, "обучение выключено") + state = self.state + symbol_stat = state.symbol_stats.get(symbol, {}) + pattern_stat = state.pattern_stats.get(pattern, {}) + symbol_adj = self._stat_adjustment(symbol_stat) + pattern_adj = self._stat_adjustment(pattern_stat) + adjustment = _clamp( + symbol_adj + pattern_adj, + -self.settings.learning_max_adjustment, + self.settings.learning_max_adjustment, + ) + samples = int(symbol_stat.get("sample_size", 0)) + int(pattern_stat.get("sample_size", 0)) + net_pnl = float(symbol_stat.get("net_pnl", 0.0)) + float(pattern_stat.get("net_pnl", 0.0)) + win_rate = _weighted_win_rate(symbol_stat, pattern_stat) + if samples < self.settings.learning_min_samples: + adjustment = 0.0 + reason = "мало закрытых сделок для вывода" + elif adjustment > 0: + reason = "прошлые сделки по символу/шаблону были лучше среднего" + elif adjustment < 0: + reason = "прошлые сделки по символу/шаблону были убыточными" + else: + reason = "статистика нейтральна" + return LearningAdjustment( + symbol=symbol, + pattern=pattern, + sample_size=samples, + net_pnl=round(net_pnl, 6), + win_rate=round(win_rate, 4), + confidence_adjustment=round(adjustment, 4), + reason=reason, + adaptive_rules=self.rules_for(symbol, pattern), + ) + + def rules_for(self, symbol: str, pattern: str = "") -> dict[str, Any]: + rules = dict(self.state.adaptive_rules or {}) + symbol_adjustments = rules.get("symbol_threshold_adjustments") or {} + pattern_adjustments = rules.get("pattern_threshold_adjustments") or {} + symbol_adjustment = float(symbol_adjustments.get(symbol, 0.0) or 0.0) + pattern_adjustment = float(pattern_adjustments.get(pattern, 0.0) or 0.0) + base_adjustment = float(rules.get("entry_threshold_adjustment", 0.0) or 0.0) + effective = _clamp( + base_adjustment + symbol_adjustment + pattern_adjustment, + -self.settings.learning_max_adjustment, + self.settings.learning_max_adjustment, + ) + rules["symbol_entry_threshold_adjustment"] = round(symbol_adjustment, 4) + rules["pattern_entry_threshold_adjustment"] = round(pattern_adjustment, 4) + rules["effective_entry_threshold_adjustment"] = round(effective, 4) + rules["symbol_blocked"] = symbol in set(rules.get("blocked_symbols") or []) + rules["pattern_blocked"] = pattern in set(rules.get("blocked_patterns") or []) + return rules + + def _stat_adjustment(self, stat: dict[str, Any]) -> float: + sample_size = int(stat.get("sample_size", 0)) + if sample_size < self.settings.learning_min_samples: + return 0.0 + net_pnl = float(stat.get("net_pnl", 0.0)) + win_rate = float(stat.get("win_rate", 0.0)) + avg_pnl = net_pnl / sample_size + raw = (win_rate - 0.5) * 0.12 + avg_pnl * 0.05 + return _clamp( + raw, + -self.settings.learning_max_adjustment / 2, + self.settings.learning_max_adjustment / 2, + ) + + +def _group_stats(trades: list[dict[str, Any]], key: str) -> dict[str, dict[str, Any]]: + buckets: dict[str, list[dict[str, Any]]] = {} + for trade in trades: + raw = str(trade.get(key) or "неизвестно").strip() or "неизвестно" + buckets.setdefault(raw, []).append(trade) + result: dict[str, dict[str, Any]] = {} + for name, rows in buckets.items(): + net = sum(float(row.get("net_pnl") or 0.0) for row in rows) + wins = sum(1 for row in rows if float(row.get("net_pnl") or 0.0) > 0) + losses = sum(1 for row in rows if float(row.get("net_pnl") or 0.0) < 0) + result[name] = { + "sample_size": len(rows), + "net_pnl": round(net, 6), + "win_count": wins, + "loss_count": losses, + "win_rate": round(wins / len(rows), 4) if rows else 0.0, + "average_net_pnl": round(net / len(rows), 6) if rows else 0.0, + } + return result + + +def _weighted_win_rate(*stats: dict[str, Any]) -> float: + wins = 0.0 + samples = 0.0 + for stat in stats: + sample_size = float(stat.get("sample_size", 0) or 0) + wins += float(stat.get("win_rate", 0.0) or 0.0) * sample_size + samples += sample_size + return wins / samples if samples else 0.0 + + +def _build_adaptive_rules( + *, + trades: list[dict[str, Any]], + settings: Settings, + total_net: float, + wins: int, + symbol_stats: dict[str, dict[str, Any]], + pattern_stats: dict[str, dict[str, Any]], + reason_stats: dict[str, dict[str, Any]], +) -> dict[str, Any]: + sample_size = len(trades) + rules = _neutral_rules(settings) + rules["sample_size"] = sample_size + rules["net_pnl"] = round(total_net, 6) + rules["win_rate"] = round(wins / sample_size, 4) if sample_size else 0.0 + rules["exit_reason_stats"] = reason_stats + if sample_size < settings.learning_min_samples: + rules["reasons"].append("мало закрытых сделок для изменения правил") + return rules + + win_rate = wins / sample_size if sample_size else 0.0 + if total_net < 0: + threshold = _clamp( + (0.5 - win_rate) * 0.12 + min(abs(total_net) / max(sample_size, 1), 0.05), + 0.0, + settings.learning_max_adjustment, + ) + rules["entry_threshold_adjustment"] = round(threshold, 4) + rules["risk_mode"] = "defensive" + rules["trade_permission"] = "capital_protection" + rules["reduce_exposure"] = True + rules["bad_market_entry_block"] = True + rules["target_total_exposure_usdt"] = round( + min(settings.max_total_exposure_usdt, max(settings.min_position_usdt, settings.starting_balance_usdt * 0.35)), + 2, + ) + rules["target_symbol_exposure_usdt"] = round( + min(settings.max_symbol_exposure_usdt, max(settings.min_position_usdt, settings.max_position_usdt * 0.5)), + 2, + ) + rules["min_hold_seconds"] = int(min(max(settings.min_hold_seconds * 2, 300), 900)) + if win_rate <= 0.25: + rules["stop_loss_percent"] = round(max(0.008, settings.stop_loss_percent * 0.85), 4) + rules["take_profit_percent"] = round( + max(settings.take_profit_percent, (_round_trip_cost_percent(settings) + 0.6) / 100), + 4, + ) + rules["reasons"].append("общая статистика обучения убыточна: вход ужесточен, риск снижен") + elif win_rate >= 0.55: + threshold = -min(settings.learning_max_adjustment / 2, (win_rate - 0.5) * 0.08) + rules["entry_threshold_adjustment"] = round(threshold, 4) + rules["risk_mode"] = "expansion" + rules["reasons"].append("общая статистика обучения положительная: вход можно немного расширить") + + for name, stat in symbol_stats.items(): + adjustment = _threshold_adjustment_from_stat(stat, settings) + if adjustment: + rules["symbol_threshold_adjustments"][name] = adjustment + if _should_block_stat(stat, settings): + rules["blocked_symbols"].append(name) + + for name, stat in pattern_stats.items(): + adjustment = _threshold_adjustment_from_stat(stat, settings) + if adjustment: + rules["pattern_threshold_adjustments"][name] = adjustment + if _should_block_stat(stat, settings): + rules["blocked_patterns"].append(name) + + for reason, stat in reason_stats.items(): + if not _is_bad_stat(stat, settings): + continue + reason_text = reason.lower() + if "ema50" in reason_text: + rules["ema_exit_mode"] = "profit_only" + rules["reasons"].append("выход по EMA50 убыточен: разрешен только при прибыли после издержек") + if "rsi" in reason_text: + rules["rsi_exit_mode"] = "profit_only" + rules["reasons"].append("выход по RSI убыточен: разрешен только при прибыли после издержек") + + rules["blocked_symbols"] = sorted(set(rules["blocked_symbols"])) + rules["blocked_patterns"] = sorted(set(rules["blocked_patterns"])) + validation = _closed_trade_validation(trades, rules) + rules["validation"] = validation + if validation["status"] == "rejected": + fallback = _neutral_rules(settings, "адаптивные правила не прошли проверку на закрытых сделках") + fallback["validation"] = validation + return fallback + return rules + + +def _neutral_rules(settings: Settings, reason: str | None = None) -> dict[str, Any]: + rules: dict[str, Any] = { + "enabled": settings.learning_enabled, + "sample_size": 0, + "net_pnl": 0.0, + "win_rate": 0.0, + "round_trip_cost_percent": round(_round_trip_cost_percent(settings), 4), + "entry_threshold_adjustment": 0.0, + "risk_mode": "neutral", + "trade_permission": "normal", + "allow_new_entries": True, + "reduce_exposure": False, + "bad_market_entry_block": False, + "target_total_exposure_usdt": settings.max_total_exposure_usdt, + "target_symbol_exposure_usdt": settings.max_symbol_exposure_usdt, + "current_total_exposure_usdt": 0.0, + "over_target_exposure": False, + "reduce_now": False, + "min_hold_seconds": settings.min_hold_seconds, + "min_exit_profit_percent": round(_round_trip_cost_percent(settings) + 0.05, 4), + "ema_exit_mode": "normal", + "rsi_exit_mode": "normal", + "stop_loss_percent": settings.stop_loss_percent, + "take_profit_percent": settings.take_profit_percent, + "trailing_stop_percent": settings.trailing_stop_percent, + "symbol_threshold_adjustments": {}, + "pattern_threshold_adjustments": {}, + "blocked_symbols": [], + "blocked_patterns": [], + "exit_reason_stats": {}, + "validation": { + "method": "closed_trade_counterfactual", + "status": "not_enough_data", + "sample_size": 0, + "baseline_net_pnl": 0.0, + "validated_net_pnl": 0.0, + "skipped_trades": 0, + "skipped_net_pnl": 0.0, + "avoided_loss_usdt": 0.0, + }, + "reasons": [], + } + if reason: + rules["reasons"].append(reason) + return rules + + +def _round_trip_cost_percent(settings: Settings) -> float: + return (settings.taker_fee_rate * 2 + settings.slippage_rate * 2) * 100 + + +def _closed_trade_validation(trades: list[dict[str, Any]], rules: dict[str, Any]) -> dict[str, Any]: + baseline_net = sum(float(trade.get("net_pnl") or 0.0) for trade in trades) + skipped_net = 0.0 + kept_net = 0.0 + skipped_count = 0 + for trade in trades: + net_pnl = float(trade.get("net_pnl") or 0.0) + if _would_skip_closed_trade(trade, rules): + skipped_net += net_pnl + skipped_count += 1 + else: + kept_net += net_pnl + avoided_loss = kept_net - baseline_net + status = "accepted" if avoided_loss >= 0 else "rejected" + return { + "method": "closed_trade_counterfactual", + "status": status, + "sample_size": len(trades), + "baseline_net_pnl": round(baseline_net, 6), + "validated_net_pnl": round(kept_net, 6), + "skipped_trades": skipped_count, + "skipped_net_pnl": round(skipped_net, 6), + "avoided_loss_usdt": round(avoided_loss, 6), + } + + +def _would_skip_closed_trade(trade: dict[str, Any], rules: dict[str, Any]) -> bool: + reason = str(trade.get("reason") or "").lower() + pattern = str(trade.get("entry_pattern") or "") + symbol = str(trade.get("symbol") or "") + net_pnl = float(trade.get("net_pnl") or 0.0) + if net_pnl >= 0: + return False + if "ema50" in reason and rules.get("ema_exit_mode") == "profit_only": + return True + if "rsi" in reason and rules.get("rsi_exit_mode") == "profit_only": + return True + if symbol in set(rules.get("blocked_symbols") or []): + return True + if pattern in set(rules.get("blocked_patterns") or []): + return True + return False + + +def _threshold_adjustment_from_stat(stat: dict[str, Any], settings: Settings) -> float: + sample_size = int(stat.get("sample_size", 0) or 0) + if sample_size < settings.learning_min_samples: + return 0.0 + net_pnl = float(stat.get("net_pnl", 0.0) or 0.0) + win_rate = float(stat.get("win_rate", 0.0) or 0.0) + avg_pnl = net_pnl / sample_size if sample_size else 0.0 + if net_pnl < 0: + raw = (0.5 - win_rate) * 0.08 + min(abs(avg_pnl), 0.08) * 0.4 + return round(_clamp(raw, 0.0, settings.learning_max_adjustment / 2), 4) + if win_rate >= 0.55 and avg_pnl > 0: + raw = -((win_rate - 0.5) * 0.05 + min(avg_pnl, 0.08) * 0.2) + return round(_clamp(raw, -settings.learning_max_adjustment / 2, 0.0), 4) + return 0.0 + + +def _is_bad_stat(stat: dict[str, Any], settings: Settings) -> bool: + sample_size = int(stat.get("sample_size", 0) or 0) + net_pnl = float(stat.get("net_pnl", 0.0) or 0.0) + win_rate = float(stat.get("win_rate", 0.0) or 0.0) + return sample_size >= settings.learning_min_samples and net_pnl < 0 and win_rate <= 0.25 + + +def _should_block_stat(stat: dict[str, Any], settings: Settings) -> bool: + sample_size = int(stat.get("sample_size", 0) or 0) + avg_pnl = float(stat.get("average_net_pnl", 0.0) or 0.0) + win_rate = float(stat.get("win_rate", 0.0) or 0.0) + return sample_size >= max(settings.learning_min_samples * 3, 9) and win_rate == 0 and avg_pnl <= -0.08 + + +def _clamp(value: float, low: float, high: float) -> float: + return max(low, min(high, value)) diff --git a/crypto_spot_bot/llm_advisor.py b/crypto_spot_bot/llm_advisor.py new file mode 100644 index 0000000..9dac1d1 --- /dev/null +++ b/crypto_spot_bot/llm_advisor.py @@ -0,0 +1,226 @@ +from __future__ import annotations + +import json +from dataclasses import asdict, dataclass, field +from typing import Any + +import requests + +from crypto_spot_bot.config import Settings +from crypto_spot_bot.models import Candle, Ticker, utc_now +from crypto_spot_bot.storage import Storage + + +REGIMES = {"uptrend", "downtrend", "range", "breakout", "breakdown", "panic", "unknown"} +RISK_LEVELS = {"low", "medium", "high"} + + +@dataclass(slots=True) +class LlmAdvice: + symbol: str + enabled: bool + model: str + market_regime: str = "unknown" + risk_level: str = "medium" + confidence_adjustment: float = 0.0 + block_entry: bool = False + grid_suitable: bool = False + reason_ru: str = "LLM Advisor не дал активной поправки." + error: str = "" + created_at: str = field(default_factory=lambda: utc_now().isoformat()) + + def as_dict(self) -> dict[str, Any]: + return asdict(self) + + +class LlmAdvisor: + def __init__(self, settings: Settings, storage: Storage): + self.settings = settings + self.storage = storage + self._cache: dict[str, LlmAdvice] = {} + + def advice_for( + self, + *, + symbol: str, + candles: list[Candle], + ticker: Ticker | None, + pattern: dict[str, Any], + learning: dict[str, Any], + open_positions_for_symbol: int, + account: dict[str, float], + ) -> LlmAdvice: + if not self.settings.llm_advisor_enabled: + return LlmAdvice( + symbol=symbol, + enabled=False, + model=self.settings.ollama_model, + reason_ru="LLM Advisor выключен.", + ) + cached = self._cache.get(symbol) + if cached and _age_seconds(cached.created_at) < self.settings.llm_advisor_min_interval_seconds: + return cached + + context = _build_context(symbol, candles, ticker, pattern, learning, open_positions_for_symbol, account) + prompt = _prompt(context, self.settings.llm_advisor_max_adjustment) + response_text = "" + error = "" + try: + response = requests.post( + f"{self.settings.ollama_base_url}/api/generate", + json={ + "model": self.settings.ollama_model, + "prompt": prompt, + "stream": False, + "options": {"temperature": 0.1}, + }, + timeout=self.settings.llm_advisor_timeout_seconds, + ) + response.raise_for_status() + payload = response.json() + response_text = str(payload.get("response", "")) + advice = self._parse(symbol, response_text) + except Exception as exc: + error = str(exc) + advice = LlmAdvice( + symbol=symbol, + enabled=True, + model=self.settings.ollama_model, + reason_ru="LLM Advisor временно недоступен; используется нейтральная поправка.", + error=error, + ) + self._cache[symbol] = advice + self.storage.insert_llm_advice( + symbol=symbol, + model=self.settings.ollama_model, + prompt_json=context, + response_text=response_text, + advice_json=advice.as_dict(), + error=error or advice.error, + ) + return advice + + def snapshot(self) -> dict[str, Any]: + return { + "enabled": self.settings.llm_advisor_enabled, + "base_url": self.settings.ollama_base_url, + "model": self.settings.ollama_model, + "min_interval_seconds": self.settings.llm_advisor_min_interval_seconds, + "max_adjustment": self.settings.llm_advisor_max_adjustment, + "items": [advice.as_dict() for advice in self._cache.values()], + } + + def _parse(self, symbol: str, response_text: str) -> LlmAdvice: + data = _extract_json(response_text) + regime = str(data.get("market_regime", "unknown")).strip().lower() + risk = str(data.get("risk_level", "medium")).strip().lower() + adjustment = _clamp_float( + data.get("confidence_adjustment", 0.0), + -self.settings.llm_advisor_max_adjustment, + self.settings.llm_advisor_max_adjustment, + ) + return LlmAdvice( + symbol=symbol, + enabled=True, + model=self.settings.ollama_model, + market_regime=regime if regime in REGIMES else "unknown", + risk_level=risk if risk in RISK_LEVELS else "medium", + confidence_adjustment=adjustment, + block_entry=bool(data.get("block_entry", False)), + grid_suitable=bool(data.get("grid_suitable", False)), + reason_ru=str(data.get("reason_ru", "LLM Advisor не объяснил вывод."))[:240], + ) + + +def _build_context( + symbol: str, + candles: list[Candle], + ticker: Ticker | None, + pattern: dict[str, Any], + learning: dict[str, Any], + open_positions_for_symbol: int, + account: dict[str, float], +) -> dict[str, Any]: + latest = candles[-1] if candles else None + return { + "mode": "paper_demo_only", + "symbol": symbol, + "objective": "reduce avoidable losing spot-long entries; do not promise profit", + "market": { + "last_price": ticker.last_price if ticker else None, + "spread_percent": ticker.spread_percent if ticker else None, + "turnover_24h": ticker.turnover_24h if ticker else None, + "change_24h": ticker.change_24h if ticker else None, + "close": latest.close if latest else None, + "rsi_14": latest.rsi_14 if latest else None, + "ema_20": latest.ema_20 if latest else None, + "ema_50": latest.ema_50 if latest else None, + "ema_200": latest.ema_200 if latest else None, + "atr_14": latest.atr_14 if latest else None, + "volume": latest.volume if latest else None, + "volume_ma_20": latest.volume_ma_20 if latest else None, + }, + "pattern": pattern, + "learning": learning, + "risk_state": { + "equity": account.get("equity"), + "cash": account.get("cash"), + "exposure": account.get("exposure"), + "drawdown": account.get("drawdown"), + "open_positions_for_symbol": open_positions_for_symbol, + }, + "allowed_output": { + "market_regime": sorted(REGIMES), + "risk_level": sorted(RISK_LEVELS), + "confidence_adjustment": "number within configured bounds", + "block_entry": "boolean; can only block buy, never force buy", + "grid_suitable": "boolean", + "reason_ru": "short Russian explanation", + }, + } + + +def _prompt(context: dict[str, Any], max_adjustment: float) -> str: + return ( + "Ты LLM Advisor для paper-only crypto spot LONG бота. " + "Ты не открываешь сделки и не обещаешь прибыль. " + "Верни только валидный JSON без markdown. " + f"confidence_adjustment должен быть от {-max_adjustment:.4f} до {max_adjustment:.4f}. " + "Если рынок падающий, шаблон отрицательный или обучение убыточное, используй отрицательную поправку или block_entry=true. " + "Если боковик и риск умеренный, можешь отметить grid_suitable=true. " + "JSON keys: market_regime, risk_level, confidence_adjustment, block_entry, grid_suitable, reason_ru. " + f"Context: {json.dumps(context, ensure_ascii=False, separators=(',', ':'))}" + ) + + +def _extract_json(text: str) -> dict[str, Any]: + stripped = text.strip() + if stripped.startswith("```"): + stripped = stripped.strip("`").strip() + if stripped.lower().startswith("json"): + stripped = stripped[4:].strip() + try: + data = json.loads(stripped) + except json.JSONDecodeError: + start = stripped.find("{") + end = stripped.rfind("}") + if start < 0 or end <= start: + raise + data = json.loads(stripped[start : end + 1]) + if not isinstance(data, dict): + raise ValueError("LLM response JSON is not an object") + return data + + +def _clamp_float(value: Any, low: float, high: float) -> float: + try: + parsed = float(value) + except (TypeError, ValueError): + parsed = 0.0 + return round(max(low, min(high, parsed)), 4) + + +def _age_seconds(created_at: str) -> float: + from datetime import datetime + + return (utc_now() - datetime.fromisoformat(created_at)).total_seconds() diff --git a/crypto_spot_bot/main.py b/crypto_spot_bot/main.py new file mode 100644 index 0000000..87898ca --- /dev/null +++ b/crypto_spot_bot/main.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +import logging + +import uvicorn + +from crypto_spot_bot.config import load_settings +from crypto_spot_bot.dashboard import create_app + + +def main() -> None: + settings = load_settings() + settings.log_path.parent.mkdir(parents=True, exist_ok=True) + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s %(message)s", + handlers=[ + logging.FileHandler(settings.log_path, encoding="utf-8"), + logging.StreamHandler(), + ], + ) + app = create_app(settings) + uvicorn.run(app, host=settings.host, port=settings.port) + + +if __name__ == "__main__": + main() diff --git a/crypto_spot_bot/market_data.py b/crypto_spot_bot/market_data.py new file mode 100644 index 0000000..1d1355d --- /dev/null +++ b/crypto_spot_bot/market_data.py @@ -0,0 +1,224 @@ +from __future__ import annotations + +import asyncio +import json +from dataclasses import asdict +from datetime import datetime +from typing import Any + +import websockets + +from crypto_spot_bot.bybit import BybitClient, Instrument, websocket_subscribe_message +from crypto_spot_bot.config import Settings +from crypto_spot_bot.indicators import add_indicators +from crypto_spot_bot.models import Candle, Ticker, utc_now +from crypto_spot_bot.storage import Storage + + +POPULAR_FALLBACK = ["BTCUSDT", "ETHUSDT", "SOLUSDT", "XRPUSDT", "DOGEUSDT", "LTCUSDT"] + + +def _float(value: Any, default: float = 0.0) -> float: + try: + return float(value) + except (TypeError, ValueError): + return default + + +class MarketData: + def __init__(self, settings: Settings, client: BybitClient, storage: Storage): + self.settings = settings + self.client = client + self.storage = storage + self.symbols: list[str] = [] + self.instruments: dict[str, Instrument] = {} + self.tickers: dict[str, Ticker] = {} + self.candles: dict[str, list[Candle]] = {} + self.orderbook_top: dict[str, tuple[float, float]] = {} + self.patterns: dict[str, dict[str, Any]] = {} + self.forecasts: dict[str, dict[str, Any]] = {} + self.last_rest_refresh_at: datetime | None = None + self.last_ws_message_at: datetime | None = None + self.ws_connected = False + self._stop_event = asyncio.Event() + + async def bootstrap(self) -> None: + self.instruments = await asyncio.to_thread(self.client.instruments) + if self.settings.symbols: + self.symbols = [ + symbol + for symbol in self.settings.symbols + if symbol in self.instruments and self.instruments[symbol].quote_coin == "USDT" + ] + elif self.settings.auto_select_symbols: + self.symbols = await asyncio.to_thread( + self.client.popular_spot_symbols, self.settings.top_symbols_count + ) + if not self.symbols: + self.symbols = [ + symbol + for symbol in POPULAR_FALLBACK[: self.settings.top_symbols_count] + if symbol in self.instruments + ] + self.storage.event("Торговые пары: " + ", ".join(self.symbols)) + await asyncio.to_thread(self.refresh_rest) + + def refresh_rest(self) -> None: + ticker_map = {ticker.symbol: ticker for ticker in self.client.spot_tickers()} + for symbol in self.symbols: + ticker = ticker_map.get(symbol) + if ticker: + self.tickers[symbol] = ticker + try: + candles = self.client.klines( + symbol=symbol, + interval=self.settings.base_interval, + limit=self.settings.kline_limit, + ) + add_indicators(candles) + self.candles[symbol] = candles + bid, ask = self.client.orderbook_top(symbol) + self.orderbook_top[symbol] = (bid, ask) + if symbol in self.tickers: + current = self.tickers[symbol] + self.tickers[symbol] = Ticker( + symbol=current.symbol, + last_price=current.last_price, + bid=bid or current.bid, + ask=ask or current.ask, + turnover_24h=current.turnover_24h, + volume_24h=current.volume_24h, + change_24h=current.change_24h, + ) + except Exception as exc: + self.storage.event(f"{symbol}: ошибка обновления REST данных: {exc}", "ERROR") + self.last_rest_refresh_at = utc_now() + + async def websocket_loop(self) -> None: + if not self.settings.websocket_enabled: + return + while not self._stop_event.is_set(): + try: + async with websockets.connect(self.settings.websocket_url, ping_interval=20) as ws: + self.ws_connected = True + await ws.send(websocket_subscribe_message(self.symbols)) + self.storage.event("Поток данных Bybit подключен") + async for raw in ws: + self.last_ws_message_at = utc_now() + self._handle_ws_message(raw) + if self._stop_event.is_set(): + break + except asyncio.CancelledError: + raise + except Exception as exc: + self.ws_connected = False + self.storage.event(f"Поток данных Bybit отключен: {exc}", "WARN") + await asyncio.sleep(5) + self.ws_connected = False + + def stop(self) -> None: + self._stop_event.set() + + def reset_stop(self) -> None: + if self._stop_event.is_set(): + self._stop_event = asyncio.Event() + + def _handle_ws_message(self, raw: str) -> None: + try: + message = json.loads(raw) + except json.JSONDecodeError: + return + topic = str(message.get("topic", "")) + data = message.get("data") + if topic.startswith("tickers.") and isinstance(data, dict): + self._handle_ticker(topic.split(".", 1)[1], data) + elif topic.startswith("kline.") and isinstance(data, list): + parts = topic.split(".") + if len(parts) >= 3: + self._handle_kline(parts[2], data) + elif topic.startswith("orderbook.") and isinstance(data, dict): + parts = topic.split(".") + if len(parts) >= 3: + self._handle_orderbook(parts[2], data) + + def _handle_ticker(self, symbol: str, data: dict[str, Any]) -> None: + current = self.tickers.get(symbol) + last_price = _float(data.get("lastPrice"), current.last_price if current else 0.0) + if last_price <= 0: + return + self.tickers[symbol] = Ticker( + symbol=symbol, + last_price=last_price, + bid=_float(data.get("bid1Price"), current.bid if current else 0.0), + ask=_float(data.get("ask1Price"), current.ask if current else 0.0), + turnover_24h=_float(data.get("turnover24h"), current.turnover_24h if current else 0.0), + volume_24h=_float(data.get("volume24h"), current.volume_24h if current else 0.0), + change_24h=_float(data.get("price24hPcnt")) * 100 + if data.get("price24hPcnt") is not None + else (current.change_24h if current else 0.0), + ) + + def _handle_kline(self, symbol: str, rows: list[dict[str, Any]]) -> None: + existing = self.candles.get(symbol, []) + by_timestamp = {candle.timestamp: candle for candle in existing} + for row in rows: + start = int(row.get("start", 0)) + if start <= 0: + continue + by_timestamp[start] = Candle( + timestamp=start, + open=_float(row.get("open")), + high=_float(row.get("high")), + low=_float(row.get("low")), + close=_float(row.get("close")), + volume=_float(row.get("volume")), + turnover=_float(row.get("turnover")), + ) + candles = sorted(by_timestamp.values(), key=lambda item: item.timestamp) + candles = candles[-self.settings.kline_limit :] + add_indicators(candles) + self.candles[symbol] = candles + + def _handle_orderbook(self, symbol: str, data: dict[str, Any]) -> None: + bids = data.get("b") or [] + asks = data.get("a") or [] + bid = _float(bids[0][0]) if bids else 0.0 + ask = _float(asks[0][0]) if asks else 0.0 + if bid > 0 and ask > 0: + self.orderbook_top[symbol] = (bid, ask) + current = self.tickers.get(symbol) + if current: + self.tickers[symbol] = Ticker( + symbol=symbol, + last_price=current.last_price, + bid=bid, + ask=ask, + turnover_24h=current.turnover_24h, + volume_24h=current.volume_24h, + change_24h=current.change_24h, + ) + + def prices(self) -> dict[str, float]: + return {symbol: ticker.last_price for symbol, ticker in self.tickers.items()} + + def snapshot(self) -> dict[str, Any]: + return { + "symbols": self.symbols, + "ws_connected": self.ws_connected, + "last_rest_refresh_at": self.last_rest_refresh_at.isoformat() + if self.last_rest_refresh_at + else None, + "last_ws_message_at": self.last_ws_message_at.isoformat() + if self.last_ws_message_at + else None, + "markets": [ + { + "ticker": self.tickers[symbol].as_dict() if symbol in self.tickers else None, + "candles": [candle.as_dict() for candle in self.candles.get(symbol, [])[-120:]], + "pattern": self.patterns.get(symbol), + "forecast": self.forecasts.get(symbol), + "instrument": asdict(self.instruments[symbol]) if symbol in self.instruments else None, + } + for symbol in self.symbols + ], + } diff --git a/crypto_spot_bot/models.py b/crypto_spot_bot/models.py new file mode 100644 index 0000000..f245334 --- /dev/null +++ b/crypto_spot_bot/models.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +from datetime import datetime, timezone +from typing import Any + + +def utc_now() -> datetime: + return datetime.now(timezone.utc) + + +@dataclass(slots=True) +class Candle: + timestamp: int + open: float + high: float + low: float + close: float + volume: float + turnover: float = 0.0 + ema_20: float | None = None + ema_50: float | None = None + ema_200: float | None = None + rsi_14: float | None = None + atr_14: float | None = None + volume_ma_20: float | None = None + + def as_dict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass(slots=True) +class Ticker: + symbol: str + last_price: float + bid: float + ask: float + turnover_24h: float + volume_24h: float + change_24h: float + updated_at: datetime = field(default_factory=utc_now) + + @property + def spread_percent(self) -> float: + if self.bid <= 0 or self.ask <= 0: + return 0.0 + mid = (self.ask + self.bid) / 2 + return ((self.ask - self.bid) / mid) * 100 if mid else 0.0 + + def as_dict(self) -> dict[str, Any]: + data = asdict(self) + data["updated_at"] = self.updated_at.isoformat() + data["spread_percent"] = self.spread_percent + return data + + +@dataclass(slots=True) +class Signal: + symbol: str + action: str + confidence: float + reason: str + diagnostics: dict[str, Any] = field(default_factory=dict) + created_at: datetime = field(default_factory=utc_now) + + def as_dict(self) -> dict[str, Any]: + data = asdict(self) + data["created_at"] = self.created_at.isoformat() + return data + + +@dataclass(slots=True) +class Position: + id: int | None + symbol: str + qty: float + entry_price: float + notional_usdt: float + entry_fee_usdt: float + stop_loss: float + take_profit: float + highest_price: float + opened_at: datetime = field(default_factory=utc_now) + entry_reason: str = "" + entry_confidence: float = 0.0 + entry_pattern: str = "" + + def mark_price(self, price: float) -> float: + return self.qty * price + + def unrealized_pnl(self, price: float) -> float: + return (price - self.entry_price) * self.qty - self.entry_fee_usdt + + def trailing_stop(self, percent: float) -> float | None: + stop = self.highest_price * (1 - percent) + return stop if stop > self.entry_price else None + + def as_dict(self, mark_price: float | None = None) -> dict[str, Any]: + data = asdict(self) + data["opened_at"] = self.opened_at.isoformat() + if mark_price is not None: + data["mark_price"] = mark_price + data["market_value"] = self.mark_price(mark_price) + data["unrealized_pnl"] = self.unrealized_pnl(mark_price) + data["unrealized_pnl_percent"] = ( + self.unrealized_pnl(mark_price) / self.notional_usdt * 100 + if self.notional_usdt + else 0.0 + ) + return data + + +@dataclass(slots=True) +class Trade: + id: int | None + symbol: str + side: str + qty: float + entry_price: float | None = None + exit_price: float | None = None + gross_pnl: float = 0.0 + fee_usdt: float = 0.0 + net_pnl: float = 0.0 + reason: str = "" + entry_pattern: str = "" + entry_confidence: float = 0.0 + opened_at: datetime | None = None + closed_at: datetime | None = None + + def as_dict(self) -> dict[str, Any]: + data = asdict(self) + data["opened_at"] = self.opened_at.isoformat() if self.opened_at else None + data["closed_at"] = self.closed_at.isoformat() if self.closed_at else None + return data + + +@dataclass(slots=True) +class BotStatus: + running: bool + mode: str + live_trading_ready: bool + symbols: list[str] + started_at: datetime | None + last_loop_at: datetime | None + message: str = "" + + def as_dict(self) -> dict[str, Any]: + data = asdict(self) + data["started_at"] = self.started_at.isoformat() if self.started_at else None + data["last_loop_at"] = self.last_loop_at.isoformat() if self.last_loop_at else None + return data diff --git a/crypto_spot_bot/patterns.py b/crypto_spot_bot/patterns.py new file mode 100644 index 0000000..d5cec1b --- /dev/null +++ b/crypto_spot_bot/patterns.py @@ -0,0 +1,229 @@ +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +from typing import Any + +from crypto_spot_bot.models import Candle, Ticker + + +@dataclass(slots=True) +class PatternResult: + label: str + score: float + description: str + tags: list[str] = field(default_factory=list) + metrics: dict[str, Any] = field(default_factory=dict) + + def as_dict(self) -> dict[str, Any]: + return asdict(self) + + +class PatternAnalyzer: + def analyze(self, candles: list[Candle], ticker: Ticker | None = None) -> PatternResult: + if len(candles) < 30: + return PatternResult( + label="мало данных", + score=0.0, + description="Недостаточно свечей для анализа шаблонов.", + tags=["мало данных"], + ) + + latest = candles[-1] + previous = candles[-2] + high20 = max(candle.high for candle in candles[-20:]) + low20 = min(candle.low for candle in candles[-20:]) + close_3 = candles[-4].close if len(candles) >= 4 else candles[0].close + close_10 = candles[-11].close if len(candles) >= 11 else candles[0].close + close_20 = candles[-21].close if len(candles) >= 21 else candles[0].close + ret_3 = _percent_change(latest.close, close_3) + ret_10 = _percent_change(latest.close, close_10) + ret_20 = _percent_change(latest.close, close_20) + body = abs(latest.close - latest.open) + lower_wick = max(0.0, min(latest.open, latest.close) - latest.low) + upper_wick = max(0.0, latest.high - max(latest.open, latest.close)) + atr_percent = (latest.atr_14 / latest.close * 100) if latest.atr_14 and latest.close else 0.0 + volume_ratio = ( + latest.volume / latest.volume_ma_20 + if latest.volume_ma_20 and latest.volume_ma_20 > 0 + else 0.0 + ) + ema_gap_percent = ( + (latest.ema_50 - latest.ema_200) / latest.ema_200 * 100 + if latest.ema_50 and latest.ema_200 + else 0.0 + ) + spread_percent = ticker.spread_percent if ticker else 0.0 + + uptrend = bool( + latest.ema_20 + and latest.ema_50 + and latest.ema_200 + and latest.ema_20 >= latest.ema_50 >= latest.ema_200 + and latest.close >= latest.ema_50 + ) + downtrend = bool( + latest.ema_20 + and latest.ema_50 + and latest.ema_200 + and latest.ema_20 <= latest.ema_50 <= latest.ema_200 + and latest.close <= latest.ema_50 + ) + pullback = bool( + latest.ema_20 + and uptrend + and latest.close <= latest.ema_20 * 1.012 + and latest.rsi_14 is not None + and 35 <= latest.rsi_14 <= 58 + ) + oversold_reversal = bool( + latest.rsi_14 is not None + and latest.rsi_14 <= 35 + and latest.close > previous.close + and lower_wick >= body * 1.2 + ) + stabilized_drop = _stabilized_drop( + candles=candles, + latest=latest, + previous=previous, + ret_3=ret_3, + ret_10=ret_10, + ret_20=ret_20, + atr_percent=atr_percent, + volume_ratio=volume_ratio, + lower_wick=lower_wick, + body=body, + ) + breakout = bool( + latest.close >= high20 * 0.995 + and volume_ratio >= 1.15 + and latest.close > latest.open + ) + breakdown = bool( + latest.close <= low20 * 1.005 + and volume_ratio >= 1.1 + and latest.close < latest.open + ) + fast_drop = bool(ret_3 <= -max(1.2, atr_percent * 1.8) or (latest.rsi_14 or 100) <= 25) + range_market = bool(abs(ret_20) <= max(0.8, atr_percent * 1.2) and abs(ema_gap_percent) <= 0.35) + volume_spike = volume_ratio >= 1.6 + + tags: list[str] = [] + if uptrend: + tags.append("восходящий тренд") + if downtrend: + tags.append("нисходящий тренд") + if pullback: + tags.append("откат к средней") + if oversold_reversal: + tags.append("перепроданность с разворотом") + if stabilized_drop: + tags.append("стабилизация после падения") + if breakout: + tags.append("пробой") + if breakdown: + tags.append("пробой вниз") + if fast_drop: + tags.append("ускоренное падение") + if range_market: + tags.append("боковик") + if volume_spike: + tags.append("объемный всплеск") + + label, score, description = _classify( + pullback=pullback, + oversold_reversal=oversold_reversal, + stabilized_drop=stabilized_drop, + breakout=breakout, + breakdown=breakdown, + fast_drop=fast_drop, + range_market=range_market, + uptrend=uptrend, + downtrend=downtrend, + ) + metrics = { + "ret_3_percent": ret_3, + "ret_10_percent": ret_10, + "ret_20_percent": ret_20, + "atr_percent": atr_percent, + "volume_ratio": volume_ratio, + "ema_gap_percent": ema_gap_percent, + "spread_percent": spread_percent, + "rsi_14": latest.rsi_14, + "high20": high20, + "low20": low20, + "body": body, + "lower_wick": lower_wick, + "upper_wick": upper_wick, + "stabilized_drop": stabilized_drop, + } + return PatternResult( + label=label, + score=round(score, 4), + description=description, + tags=tags or ["нейтрально"], + metrics=metrics, + ) + + +def _classify( + *, + pullback: bool, + oversold_reversal: bool, + stabilized_drop: bool, + breakout: bool, + breakdown: bool, + fast_drop: bool, + range_market: bool, + uptrend: bool, + downtrend: bool, +) -> tuple[str, float, str]: + if fast_drop and breakdown: + return "ускоренное падение", 0.18, "Цена быстро падает на повышенном объеме; входы ограничиваются." + if breakdown: + return "пробой вниз", 0.24, "Цена у нижней границы диапазона с давлением продавцов." + if pullback: + return "трендовый откат", 0.76, "Восходящий тренд сохраняется, цена откатилась к средней." + if oversold_reversal: + return "разворот после перепроданности", 0.68, "RSI низкий, но последняя свеча показывает попытку разворота." + if stabilized_drop: + return "стабилизация после падения", 0.58, "После снижения падение замедлилось; возможен короткий отскок." + if breakout: + return "пробой вверх", 0.72, "Цена обновляет верхнюю область диапазона с подтверждением объемом." + if uptrend: + return "восходящее продолжение", 0.64, "EMA и цена подтверждают восходящее продолжение." + if range_market: + return "боковик", 0.48, "Цена движется в диапазоне без сильного направления." + if downtrend: + return "нисходящий тренд", 0.28, "EMA и цена показывают нисходящее направление." + return "нейтрально", 0.50, "Сильного шаблона входа не обнаружено." + + +def _percent_change(current: float, previous: float) -> float: + return ((current - previous) / previous * 100) if previous else 0.0 + + +def _stabilized_drop( + *, + candles: list[Candle], + latest: Candle, + previous: Candle, + ret_3: float, + ret_10: float, + ret_20: float, + atr_percent: float, + volume_ratio: float, + lower_wick: float, + body: float, +) -> bool: + recent_drop = ret_10 <= -max(0.35, atr_percent * 1.1) or ret_20 <= -max(0.6, atr_percent * 1.6) + if not recent_drop or latest.rsi_14 is None or latest.rsi_14 > 52: + return False + recent_lows = [candle.low for candle in candles[-5:-1]] + no_new_low = bool(recent_lows) and latest.low >= min(recent_lows) * 0.999 + bounce_from_low = ((latest.close - min(candle.low for candle in candles[-6:])) / latest.close * 100) if latest.close else 0.0 + body_base = max(body, latest.close * 0.0001) + absorption = lower_wick >= body_base * 0.6 or bounce_from_low >= max(0.08, atr_percent * 0.3) + momentum_stabilized = latest.close >= previous.close or abs(ret_3) <= max(0.25, atr_percent * 0.8) or no_new_low + volume_present = volume_ratio >= 0.55 + continuing_drop = latest.close < previous.close and not no_new_low and ret_3 <= -max(0.6, atr_percent * 1.2) + return bool(momentum_stabilized and absorption and volume_present and not continuing_drop) diff --git a/crypto_spot_bot/storage.py b/crypto_spot_bot/storage.py new file mode 100644 index 0000000..529c556 --- /dev/null +++ b/crypto_spot_bot/storage.py @@ -0,0 +1,360 @@ +from __future__ import annotations + +import json +import sqlite3 +from contextlib import contextmanager +from pathlib import Path +from typing import Any, Iterator + +from crypto_spot_bot.models import Position, Signal, Trade, utc_now + + +class Storage: + def __init__(self, path: str | Path): + self.path = Path(path) + self.path.parent.mkdir(parents=True, exist_ok=True) + self.init_schema() + + @contextmanager + def connect(self) -> Iterator[sqlite3.Connection]: + conn = sqlite3.connect(self.path) + conn.row_factory = sqlite3.Row + try: + yield conn + conn.commit() + finally: + conn.close() + + def init_schema(self) -> None: + with self.connect() as conn: + conn.executescript( + """ + CREATE TABLE IF NOT EXISTS positions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + symbol TEXT NOT NULL, + qty REAL NOT NULL, + entry_price REAL NOT NULL, + notional_usdt REAL NOT NULL, + entry_fee_usdt REAL NOT NULL DEFAULT 0, + stop_loss REAL NOT NULL, + take_profit REAL NOT NULL, + highest_price REAL NOT NULL, + opened_at TEXT NOT NULL, + entry_reason TEXT NOT NULL DEFAULT '', + entry_confidence REAL NOT NULL DEFAULT 0, + entry_pattern TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'OPEN' + ); + CREATE TABLE IF NOT EXISTS trades ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + symbol TEXT NOT NULL, + side TEXT NOT NULL, + qty REAL NOT NULL, + entry_price REAL, + exit_price REAL, + gross_pnl REAL NOT NULL DEFAULT 0, + fee_usdt REAL NOT NULL DEFAULT 0, + net_pnl REAL NOT NULL DEFAULT 0, + reason TEXT NOT NULL DEFAULT '', + entry_pattern TEXT NOT NULL DEFAULT '', + entry_confidence REAL NOT NULL DEFAULT 0, + opened_at TEXT, + closed_at TEXT + ); + CREATE TABLE IF NOT EXISTS signals ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + symbol TEXT NOT NULL, + action TEXT NOT NULL, + confidence REAL NOT NULL, + reason TEXT NOT NULL, + diagnostics_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS equity ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + equity REAL NOT NULL, + cash REAL NOT NULL, + exposure REAL NOT NULL, + drawdown REAL NOT NULL, + created_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + level TEXT NOT NULL, + message TEXT NOT NULL, + created_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS runtime ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS llm_advice ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + symbol TEXT NOT NULL, + model TEXT NOT NULL, + prompt_json TEXT NOT NULL DEFAULT '{}', + response_text TEXT NOT NULL DEFAULT '', + advice_json TEXT NOT NULL DEFAULT '{}', + error TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL + ); + """ + ) + columns = { + row["name"] + for row in conn.execute("PRAGMA table_info(positions)").fetchall() + } + if "entry_fee_usdt" not in columns: + conn.execute( + "ALTER TABLE positions ADD COLUMN entry_fee_usdt REAL NOT NULL DEFAULT 0" + ) + for column, definition in { + "entry_reason": "TEXT NOT NULL DEFAULT ''", + "entry_confidence": "REAL NOT NULL DEFAULT 0", + "entry_pattern": "TEXT NOT NULL DEFAULT ''", + }.items(): + if column not in columns: + conn.execute(f"ALTER TABLE positions ADD COLUMN {column} {definition}") + trade_columns = { + row["name"] + for row in conn.execute("PRAGMA table_info(trades)").fetchall() + } + for column, definition in { + "entry_pattern": "TEXT NOT NULL DEFAULT ''", + "entry_confidence": "REAL NOT NULL DEFAULT 0", + }.items(): + if column not in trade_columns: + conn.execute(f"ALTER TABLE trades ADD COLUMN {column} {definition}") + + def insert_position(self, position: Position) -> int: + with self.connect() as conn: + cur = conn.execute( + """ + INSERT INTO positions ( + symbol, qty, entry_price, notional_usdt, entry_fee_usdt, stop_loss, + take_profit, highest_price, opened_at, entry_reason, + entry_confidence, entry_pattern, status + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'OPEN') + """, + ( + position.symbol, + position.qty, + position.entry_price, + position.notional_usdt, + position.entry_fee_usdt, + position.stop_loss, + position.take_profit, + position.highest_price, + position.opened_at.isoformat(), + position.entry_reason, + position.entry_confidence, + position.entry_pattern, + ), + ) + return int(cur.lastrowid) + + def close_position(self, position_id: int) -> None: + with self.connect() as conn: + conn.execute("UPDATE positions SET status='CLOSED' WHERE id=?", (position_id,)) + + def update_position_highest(self, position_id: int, highest_price: float) -> None: + with self.connect() as conn: + conn.execute( + "UPDATE positions SET highest_price=? WHERE id=? AND status='OPEN'", + (highest_price, position_id), + ) + + def open_positions(self) -> list[Position]: + with self.connect() as conn: + rows = conn.execute( + "SELECT * FROM positions WHERE status='OPEN' ORDER BY opened_at" + ).fetchall() + return [ + Position( + id=int(row["id"]), + symbol=row["symbol"], + qty=float(row["qty"]), + entry_price=float(row["entry_price"]), + notional_usdt=float(row["notional_usdt"]), + entry_fee_usdt=float(row["entry_fee_usdt"]), + stop_loss=float(row["stop_loss"]), + take_profit=float(row["take_profit"]), + highest_price=float(row["highest_price"]), + opened_at=_parse_datetime(row["opened_at"]), + entry_reason=row["entry_reason"], + entry_confidence=float(row["entry_confidence"]), + entry_pattern=row["entry_pattern"], + ) + for row in rows + ] + + def insert_trade(self, trade: Trade) -> int: + with self.connect() as conn: + cur = conn.execute( + """ + INSERT INTO trades ( + symbol, side, qty, entry_price, exit_price, gross_pnl, + fee_usdt, net_pnl, reason, entry_pattern, entry_confidence, + opened_at, closed_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + trade.symbol, + trade.side, + trade.qty, + trade.entry_price, + trade.exit_price, + trade.gross_pnl, + trade.fee_usdt, + trade.net_pnl, + trade.reason, + trade.entry_pattern, + trade.entry_confidence, + trade.opened_at.isoformat() if trade.opened_at else None, + trade.closed_at.isoformat() if trade.closed_at else None, + ), + ) + return int(cur.lastrowid) + + def recent_trades(self, limit: int = 50) -> list[dict[str, Any]]: + with self.connect() as conn: + rows = conn.execute("SELECT * FROM trades ORDER BY id DESC LIMIT ?", (limit,)).fetchall() + return [dict(row) for row in rows] + + def closed_trades(self, limit: int = 200) -> list[dict[str, Any]]: + with self.connect() as conn: + rows = conn.execute( + """ + SELECT * FROM trades + WHERE side='SELL' AND closed_at IS NOT NULL + ORDER BY id DESC + LIMIT ? + """, + (limit,), + ).fetchall() + return [dict(row) for row in rows] + + def insert_signal(self, signal: Signal) -> None: + with self.connect() as conn: + conn.execute( + """ + INSERT INTO signals (symbol, action, confidence, reason, diagnostics_json, created_at) + VALUES (?, ?, ?, ?, ?, ?) + """, + ( + signal.symbol, + signal.action, + signal.confidence, + signal.reason, + json.dumps(signal.diagnostics, ensure_ascii=False), + signal.created_at.isoformat(), + ), + ) + + def recent_signals(self, limit: int = 80) -> list[dict[str, Any]]: + with self.connect() as conn: + rows = conn.execute("SELECT * FROM signals ORDER BY id DESC LIMIT ?", (limit,)).fetchall() + return [dict(row) for row in rows] + + def insert_equity(self, equity: float, cash: float, exposure: float, drawdown: float) -> None: + with self.connect() as conn: + conn.execute( + "INSERT INTO equity (equity, cash, exposure, drawdown, created_at) VALUES (?, ?, ?, ?, ?)", + (equity, cash, exposure, drawdown, utc_now().isoformat()), + ) + + def latest_equity(self) -> dict[str, Any] | None: + with self.connect() as conn: + row = conn.execute("SELECT * FROM equity ORDER BY id DESC LIMIT 1").fetchone() + return dict(row) if row else None + + def event(self, message: str, level: str = "INFO") -> None: + with self.connect() as conn: + conn.execute( + "INSERT INTO events (level, message, created_at) VALUES (?, ?, ?)", + (level, message, utc_now().isoformat()), + ) + + def recent_events(self, limit: int = 80) -> list[dict[str, Any]]: + with self.connect() as conn: + rows = conn.execute("SELECT * FROM events ORDER BY id DESC LIMIT ?", (limit,)).fetchall() + return [dict(row) for row in rows] + + def insert_llm_advice( + self, + *, + symbol: str, + model: str, + prompt_json: dict[str, Any], + response_text: str, + advice_json: dict[str, Any], + error: str = "", + ) -> None: + with self.connect() as conn: + conn.execute( + """ + INSERT INTO llm_advice ( + symbol, model, prompt_json, response_text, advice_json, error, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + symbol, + model, + json.dumps(prompt_json, ensure_ascii=False), + response_text, + json.dumps(advice_json, ensure_ascii=False), + error, + utc_now().isoformat(), + ), + ) + + def recent_llm_advice(self, limit: int = 80) -> list[dict[str, Any]]: + with self.connect() as conn: + rows = conn.execute("SELECT * FROM llm_advice ORDER BY id DESC LIMIT ?", (limit,)).fetchall() + items: list[dict[str, Any]] = [] + for row in rows: + item = dict(row) + item["prompt"] = _json_or_default(item.pop("prompt_json", "{}"), {}) + item["advice"] = _json_or_default(item.pop("advice_json", "{}"), {}) + items.append(item) + return items + + def set_runtime(self, key: str, value: Any) -> None: + with self.connect() as conn: + conn.execute( + """ + INSERT INTO runtime (key, value, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at + """, + (key, json.dumps(value, ensure_ascii=False), utc_now().isoformat()), + ) + + def get_runtime(self, key: str, default: Any = None) -> Any: + with self.connect() as conn: + row = conn.execute("SELECT value FROM runtime WHERE key=?", (key,)).fetchone() + if not row: + return default + try: + return json.loads(row["value"]) + except json.JSONDecodeError: + return default + + def clear_all(self) -> None: + with self.connect() as conn: + for table in ("positions", "trades", "signals", "equity", "events", "runtime", "llm_advice"): + conn.execute(f"DELETE FROM {table}") + + +def _json_or_default(value: str, default: Any) -> Any: + try: + return json.loads(value) + except json.JSONDecodeError: + return default + + +def _parse_datetime(value: str): + from datetime import datetime + + return datetime.fromisoformat(value) diff --git a/crypto_spot_bot/strategy.py b/crypto_spot_bot/strategy.py new file mode 100644 index 0000000..5484c5a --- /dev/null +++ b/crypto_spot_bot/strategy.py @@ -0,0 +1,839 @@ +from __future__ import annotations + +from crypto_spot_bot.config import Settings +from crypto_spot_bot.models import Candle, Position, Signal, Ticker, utc_now + + +NEGATIVE_LONG_PATTERNS = {"нисходящий тренд", "пробой вниз", "ускоренное падение"} + + +class SpotStrategy: + def __init__(self, settings: Settings): + self.settings = settings + + def entry_signal( + self, + symbol: str, + candles: list[Candle], + ticker: Ticker | None, + open_positions_for_symbol: int, + pattern: dict | None = None, + learning: dict | None = None, + llm: dict | None = None, + forecast: dict | None = None, + ) -> Signal: + if ticker is None: + return Signal(symbol, "HOLD", 0.0, "нет ticker-данных") + if len(candles) < 200: + return Signal(symbol, "HOLD", 0.0, "недостаточно свечей для EMA200") + latest = candles[-1] + previous = candles[-2] if len(candles) >= 2 else latest + if not _has_entry_indicators(latest): + return Signal(symbol, "HOLD", 0.0, "индикаторы еще не готовы") + spread_ok = ticker.spread_percent <= self.settings.max_spread_percent + liquidity_ok = ticker.turnover_24h >= self.settings.min_24h_turnover_usdt + trend_ok = latest.close > latest.ema_200 or latest.ema_20 > latest.ema_50 + pullback_ok = 35 <= latest.rsi_14 <= 58 and latest.close <= latest.ema_20 * 1.012 + momentum_ok = latest.ema_20 >= latest.ema_50 or latest.close > previous.close + volume_ok = latest.volume_ma_20 is not None and latest.volume >= latest.volume_ma_20 * 0.75 + atr_percent = (latest.atr_14 / latest.close) * 100 if latest.close else 0.0 + volatility_ok = 0.04 <= atr_percent <= 6.0 + + weights = { + "spread": 0.18, + "liquidity": 0.14, + "trend": 0.16, + "pullback": 0.18, + "momentum": 0.14, + "volume": 0.10, + "volatility": 0.10, + } + score = ( + weights["spread"] * float(spread_ok) + + weights["liquidity"] * float(liquidity_ok) + + weights["trend"] * float(trend_ok) + + weights["pullback"] * float(pullback_ok) + + weights["momentum"] * float(momentum_ok) + + weights["volume"] * float(volume_ok) + + weights["volatility"] * float(volatility_ok) + ) + pattern = pattern or {} + learning = learning or {} + llm = llm or {} + forecast = forecast or {} + pattern_label = str(pattern.get("label") or "") + pattern_score = float(pattern.get("score", 0.5) or 0.5) + pattern_adjustment = ( + (pattern_score - 0.5) * self.settings.pattern_score_weight + if self.settings.pattern_analysis_enabled + else 0.0 + ) + learning_adjustment = float(learning.get("confidence_adjustment", 0.0) or 0.0) + forecast_adjustment = ( + float(forecast.get("confidence_adjustment", 0.0) or 0.0) + if self.settings.time_series_forecast_enabled + else 0.0 + ) + adaptive = _adaptive_rules(learning) + adaptive_entry_adjustment = _adaptive_threshold_adjustment(adaptive) + falling_market = _falling_market(latest, previous, pattern_label, llm) + llm_adjustment = float(llm.get("confidence_adjustment", 0.0) or 0.0) + rebound = _rebound_state( + settings=self.settings, + candles=candles, + latest=latest, + previous=previous, + pattern=pattern, + llm=llm, + spread_ok=spread_ok, + liquidity_ok=liquidity_ok, + volume_ok=volume_ok, + volatility_ok=volatility_ok, + atr_percent=atr_percent, + ) + adaptive_blocks_entry = _adaptive_blocks_entry(adaptive, falling_market, rebound["active"]) + base_final_score = score + pattern_adjustment + learning_adjustment + llm_adjustment + forecast_adjustment + rebound_entry_score = float(rebound.get("entry_score", 0.0) or 0.0) + final_score = _clamp(max(base_final_score, rebound_entry_score), 0.0, 1.0) + learning_blocks_entry = _learning_blocks_entry( + learning=learning, + learning_adjustment=learning_adjustment, + min_samples=self.settings.learning_min_samples, + max_adjustment=self.settings.learning_max_adjustment, + enabled=self.settings.learning_enabled, + ) + llm_blocks_entry = bool(llm.get("block_entry", False)) and self.settings.llm_advisor_enabled + forecast_blocks_entry = ( + bool(forecast.get("block_entry", False)) + and self.settings.time_series_forecast_enabled + and bool(forecast.get("usable", False)) + ) + grid = _grid_state( + settings=self.settings, + latest=latest, + pattern=pattern, + llm=llm, + atr_percent=atr_percent, + spread_ok=spread_ok, + liquidity_ok=liquidity_ok, + volatility_ok=volatility_ok, + ) + base_entry_threshold = ( + self.settings.grid_entry_confidence + if grid["active"] + else self.settings.rebound_entry_confidence + if rebound["active"] + else self.settings.min_signal_confidence + ) + entry_threshold = _clamp(base_entry_threshold + adaptive_entry_adjustment, 0.45, 0.92) + negative_pattern = ( + self.settings.pattern_analysis_enabled + and pattern_label in NEGATIVE_LONG_PATTERNS + and pattern_score <= 0.32 + ) + pattern_blocks_entry = negative_pattern and not ( + rebound["active"] and rebound_entry_score >= entry_threshold + ) + position_notional = _position_notional( + settings=self.settings, + final_score=final_score, + grid_active=grid["active"], + rebound_active=rebound["active"], + llm=llm, + forecast=forecast, + adaptive=adaptive, + ) + trade_mode = "GRID" if grid["active"] else "REBOUND" if rebound["active"] else "NORMAL" + + diagnostics = { + "base_score": round(score, 4), + "pattern_adjustment": round(pattern_adjustment, 4), + "learning_adjustment": round(learning_adjustment, 4), + "llm_adjustment": round(llm_adjustment, 4), + "forecast_adjustment": round(forecast_adjustment, 4), + "rebound_probability": rebound["probability"], + "rebound_entry_score": round(rebound_entry_score, 4), + "final_score": round(final_score, 4), + "entry_blocked_by_pattern": pattern_blocks_entry, + "entry_blocked_by_learning": learning_blocks_entry, + "entry_blocked_by_adaptive_rules": adaptive_blocks_entry, + "adaptive_block_reason": _adaptive_block_reason(adaptive, falling_market, rebound["active"]), + "entry_blocked_by_llm": llm_blocks_entry, + "entry_blocked_by_forecast": forecast_blocks_entry, + "falling_market": falling_market, + "open_positions_for_symbol": open_positions_for_symbol, + "position_notional_usdt": position_notional, + "trade_mode": trade_mode, + "base_entry_threshold": round(base_entry_threshold, 4), + "adaptive_entry_threshold_adjustment": round(adaptive_entry_adjustment, 4), + "entry_threshold": round(entry_threshold, 4), + "adaptive_rules": adaptive, + "stop_loss_percent": _adaptive_percent( + adaptive, "stop_loss_percent", self.settings.stop_loss_percent, 0.003, 0.08 + ), + "take_profit_percent": _adaptive_percent( + adaptive, "take_profit_percent", self.settings.take_profit_percent, 0.003, 0.20 + ), + "trailing_stop_percent": _adaptive_percent( + adaptive, "trailing_stop_percent", self.settings.trailing_stop_percent, 0.003, 0.08 + ), + "grid": grid, + "rebound": rebound, + "pattern": pattern, + "learning": learning, + "llm": llm, + "forecast": forecast, + "spread_percent": round(ticker.spread_percent, 5), + "turnover_24h": ticker.turnover_24h, + "rsi_14": latest.rsi_14, + "ema_20": latest.ema_20, + "ema_50": latest.ema_50, + "ema_200": latest.ema_200, + "volume": latest.volume, + "volume_ma_20": latest.volume_ma_20, + "atr_percent": atr_percent, + "checks": { + "spread_ok": spread_ok, + "liquidity_ok": liquidity_ok, + "trend_ok": trend_ok, + "pullback_ok": pullback_ok, + "momentum_ok": momentum_ok, + "volume_ok": volume_ok, + "volatility_ok": volatility_ok, + "rebound_active": rebound["active"], + }, + } + suffix = _decision_suffix(pattern, learning, llm) + if pattern_blocks_entry: + return Signal( + symbol, + "HOLD", + round(final_score, 4), + f"покупка заблокирована отрицательным LONG-шаблоном: {pattern_label}{suffix}", + diagnostics, + ) + if learning_blocks_entry: + return Signal( + symbol, + "HOLD", + round(final_score, 4), + f"покупка заблокирована обучением: похожие сделки были убыточными{suffix}", + diagnostics, + ) + if adaptive_blocks_entry: + return Signal( + symbol, + "HOLD", + round(final_score, 4), + f"покупка заблокирована адаптивными правилами обучения: символ или шаблон в стоп-листе{suffix}", + diagnostics, + ) + if llm_blocks_entry: + return Signal( + symbol, + "HOLD", + round(final_score, 4), + f"покупка заблокирована LLM Advisor: {llm.get('reason_ru') or 'модель вернула block_entry=true'}{suffix}", + diagnostics, + ) + if forecast_blocks_entry: + return Signal( + symbol, + "HOLD", + round(final_score, 4), + f"покупка заблокирована прогнозом временного ряда: {forecast.get('reason') or 'ожидаемое движение вниз'}{suffix}", + diagnostics, + ) + if grid["active"] and not grid["buy_zone"] and not rebound["active"]: + return Signal( + symbol, + "HOLD", + round(final_score, 4), + f"grid-режим активен, но цена не в зоне покупки: {grid['reason']}{suffix}", + diagnostics, + ) + if final_score >= entry_threshold: + mode_reason = ( + f"grid-режим: покупка в нижней части диапазона, размер {position_notional:.2f} USDT" + if grid["active"] + else f"rebound-сценарий: падение стабилизировалось, вероятность {rebound['probability']:.2f}, размер {position_notional:.2f} USDT" + if rebound["active"] + else f"условия покупки набрали достаточную оценку, размер {position_notional:.2f} USDT" + ) + return Signal( + symbol, + "BUY", + round(final_score, 4), + f"{mode_reason}{suffix}", + diagnostics, + ) + return Signal( + symbol, + "HOLD", + round(final_score, 4), + f"оценка входа ниже порога{suffix}", + diagnostics, + ) + + def _legacy_exit_signal( + self, + position: Position, + candles: list[Candle], + ticker: Ticker | None, + learning: dict | None = None, + ) -> Signal: + if ticker is None: + return Signal(position.symbol, "HOLD", 0.0, "нет ticker-данных для выхода") + if not candles: + return Signal(position.symbol, "HOLD", 0.0, "нет свечей для выхода") + + latest = candles[-1] + previous = candles[-2] if len(candles) >= 2 else latest + price = ticker.last_price + trailing = position.trailing_stop(self.settings.trailing_stop_percent) + diagnostics = { + "price": price, + "entry_price": position.entry_price, + "stop_loss": position.stop_loss, + "take_profit": position.take_profit, + "highest_price": position.highest_price, + "trailing_stop": trailing, + "rsi_14": latest.rsi_14, + "ema_20": latest.ema_20, + "ema_50": latest.ema_50, + } + if price <= position.stop_loss: + return Signal(position.symbol, "SELL", 1.0, "сработал стоп-лосс", diagnostics) + if price >= position.take_profit: + return Signal(position.symbol, "SELL", 0.96, "сработал тейк-профит", diagnostics) + if trailing is not None and price <= trailing: + return Signal(position.symbol, "SELL", 0.90, "сработал трейлинг-стоп выше цены входа", diagnostics) + hold_seconds = (utc_now() - position.opened_at).total_seconds() + diagnostics["hold_seconds"] = hold_seconds + if hold_seconds < self.settings.min_hold_seconds: + return Signal(position.symbol, "HOLD", 0.45, "минимальное время удержания еще не прошло", diagnostics) + if adaptive.get("reduce_exposure") and adaptive.get("reduce_now"): + return Signal( + position.symbol, + "SELL", + 0.88, + "обучение снижает общую экспозицию до целевого уровня", + diagnostics, + ) + if latest.rsi_14 is not None and latest.rsi_14 >= 72 and latest.close < previous.close: + return Signal(position.symbol, "SELL", 0.76, "RSI высокий и цена начала снижаться", diagnostics) + if ( + latest.ema_20 is not None + and latest.ema_50 is not None + and latest.ema_20 < latest.ema_50 + and latest.close < latest.ema_50 + ): + return Signal(position.symbol, "SELL", 0.70, "краткосрочный тренд ослаб ниже EMA50", diagnostics) + return Signal(position.symbol, "HOLD", 0.35, "условия выхода не выполнены", diagnostics) + + + def exit_signal( + self, + position: Position, + candles: list[Candle], + ticker: Ticker | None, + learning: dict | None = None, + forecast: dict | None = None, + ) -> Signal: + if ticker is None: + return Signal(position.symbol, "HOLD", 0.0, "нет ticker-данных для выхода") + if not candles: + return Signal(position.symbol, "HOLD", 0.0, "нет свечей для выхода") + + latest = candles[-1] + previous = candles[-2] if len(candles) >= 2 else latest + price = ticker.last_price + adaptive = _adaptive_rules(learning or {}) + forecast = forecast or {} + stop_loss_percent = _adaptive_percent( + adaptive, "stop_loss_percent", self.settings.stop_loss_percent, 0.003, 0.08 + ) + take_profit_percent = _adaptive_percent( + adaptive, "take_profit_percent", self.settings.take_profit_percent, 0.003, 0.20 + ) + trailing_percent = _adaptive_percent( + adaptive, "trailing_stop_percent", self.settings.trailing_stop_percent, 0.003, 0.08 + ) + effective_stop_loss = max(position.stop_loss, position.entry_price * (1 - stop_loss_percent)) + effective_take_profit = position.entry_price * (1 + take_profit_percent) + trailing = position.trailing_stop(trailing_percent) + estimated_exit_net_percent = _estimated_exit_net_percent(position, price, self.settings) + diagnostics = { + "price": price, + "entry_price": position.entry_price, + "stop_loss": effective_stop_loss, + "take_profit": effective_take_profit, + "highest_price": position.highest_price, + "trailing_stop": trailing, + "rsi_14": latest.rsi_14, + "ema_20": latest.ema_20, + "ema_50": latest.ema_50, + "adaptive_rules": adaptive, + "forecast": forecast, + "estimated_exit_net_percent": round(estimated_exit_net_percent, 4), + "min_exit_profit_percent": float(adaptive.get("min_exit_profit_percent", 0.0) or 0.0), + } + if price <= effective_stop_loss: + return Signal(position.symbol, "SELL", 1.0, "сработал стоп-лосс", diagnostics) + if price >= effective_take_profit: + return Signal(position.symbol, "SELL", 0.96, "сработал тейк-профит", diagnostics) + if trailing is not None and price <= trailing: + return Signal(position.symbol, "SELL", 0.90, "сработал трейлинг-стоп выше цены входа", diagnostics) + hold_seconds = (utc_now() - position.opened_at).total_seconds() + diagnostics["hold_seconds"] = hold_seconds + adaptive_min_hold = int(float(adaptive.get("min_hold_seconds", self.settings.min_hold_seconds) or 0)) + min_hold_seconds = max(self.settings.min_hold_seconds, adaptive_min_hold) + diagnostics["min_hold_seconds"] = min_hold_seconds + if adaptive.get("reduce_exposure") and adaptive.get("reduce_now") and hold_seconds >= min_hold_seconds: + return Signal( + position.symbol, + "SELL", + 0.88, + "обучение снижает общую экспозицию до целевого уровня", + diagnostics, + ) + if hold_seconds < min_hold_seconds: + return Signal(position.symbol, "HOLD", 0.45, "минимальное время удержания еще не прошло", diagnostics) + forecast_exit = _forecast_exit_signal( + forecast=forecast, + position=position, + price=price, + estimated_exit_net_percent=estimated_exit_net_percent, + stop_loss_percent=stop_loss_percent, + min_edge_percent=self.settings.time_series_min_edge_percent, + ) + if forecast_exit is not None: + action, confidence, reason = forecast_exit + return Signal(position.symbol, action, confidence, reason, diagnostics) + if latest.rsi_14 is not None and latest.rsi_14 >= 72 and latest.close < previous.close: + if _adaptive_indicator_exit_allowed(adaptive, "rsi_exit_mode", estimated_exit_net_percent): + return Signal(position.symbol, "SELL", 0.76, "RSI высокий и цена начала снижаться", diagnostics) + return Signal( + position.symbol, + "HOLD", + 0.44, + "обучение удерживает позицию: RSI-выход убыточен после издержек", + diagnostics, + ) + if ( + latest.ema_20 is not None + and latest.ema_50 is not None + and latest.ema_20 < latest.ema_50 + and latest.close < latest.ema_50 + ): + if _adaptive_indicator_exit_allowed(adaptive, "ema_exit_mode", estimated_exit_net_percent): + return Signal(position.symbol, "SELL", 0.70, "краткосрочный тренд ослаб ниже EMA50", diagnostics) + return Signal( + position.symbol, + "HOLD", + 0.44, + "обучение удерживает позицию: EMA50-выход убыточен после издержек", + diagnostics, + ) + return Signal(position.symbol, "HOLD", 0.35, "условия выхода не выполнены", diagnostics) + + +def _has_entry_indicators(candle: Candle) -> bool: + return all( + value is not None + for value in ( + candle.ema_20, + candle.ema_50, + candle.ema_200, + candle.rsi_14, + candle.atr_14, + candle.volume_ma_20, + ) + ) + + +def _decision_suffix(pattern: dict, learning: dict, llm: dict | None = None) -> str: + parts: list[str] = [] + label = pattern.get("label") + if label: + parts.append(f"шаблон: {label}") + reason = learning.get("reason") + adjustment = float(learning.get("confidence_adjustment", 0.0) or 0.0) + if reason and adjustment != 0: + parts.append(f"обучение: {reason}") + llm = llm or {} + llm_reason = llm.get("reason_ru") + llm_adjustment = float(llm.get("confidence_adjustment", 0.0) or 0.0) + if llm_reason and (llm_adjustment != 0 or llm.get("block_entry")): + parts.append(f"LLM: {llm_reason}") + return " (" + "; ".join(parts) + ")" if parts else "" + + +def _clamp(value: float, low: float, high: float) -> float: + return max(low, min(high, value)) + + +def _position_notional( + *, + settings: Settings, + final_score: float, + grid_active: bool, + rebound_active: bool, + llm: dict, + forecast: dict | None = None, + adaptive: dict | None = None, +) -> float: + low = max(0.0, settings.min_position_usdt) + high = max(low, settings.max_position_usdt) + if grid_active: + high = max(low, min(high, settings.grid_max_position_usdt)) + elif rebound_active: + high = max(low, min(high, settings.rebound_max_position_usdt)) + denominator = max(0.0001, 1.0 - settings.min_signal_confidence) + confidence_ratio = _clamp((final_score - settings.min_signal_confidence) / denominator, 0.0, 1.0) + raw = low + (high - low) * confidence_ratio + risk = str(llm.get("risk_level", "medium")).lower() + if risk == "high": + raw *= 0.55 + elif risk == "low": + raw *= 1.10 + forecast = forecast or {} + if forecast.get("usable"): + probability_up = _safe_float(forecast.get("probability_up"), 0.5) + volatility_percent = _safe_float(forecast.get("volatility_percent"), 0.0) + if probability_up < 0.52: + raw *= 0.75 + elif probability_up >= 0.60: + raw *= 1.08 + if volatility_percent >= 0.8: + raw *= 0.70 + risk_mode = str((adaptive or {}).get("risk_mode", "neutral")).lower() + if risk_mode == "defensive": + raw *= 0.65 + elif risk_mode == "expansion": + raw *= 1.10 + return round(_clamp(raw, low, high), 2) + + +def _grid_state( + *, + settings: Settings, + latest: Candle, + pattern: dict, + llm: dict, + atr_percent: float, + spread_ok: bool, + liquidity_ok: bool, + volatility_ok: bool, +) -> dict: + metrics = pattern.get("metrics") or {} + high20 = _safe_float(metrics.get("high20"), latest.high) + low20 = _safe_float(metrics.get("low20"), latest.low) + width = max(0.0, high20 - low20) + range_position = _clamp((latest.close - low20) / width, 0.0, 1.0) if width else 0.5 + range_width_percent = (width / latest.close * 100) if latest.close else 0.0 + label = str(pattern.get("label", "")).lower() + tags = {str(tag).lower() for tag in pattern.get("tags", [])} + llm_regime = str(llm.get("market_regime", "")).lower() + llm_grid = bool(llm.get("grid_suitable", False)) + ema_gap = abs(_safe_float(metrics.get("ema_gap_percent"), 999.0)) + ret_20 = abs(_safe_float(metrics.get("ret_20_percent"), 999.0)) + range_like = ( + "боковик" in label + or "боковик" in tags + or llm_regime == "range" + or llm_grid + or (ema_gap <= 0.35 and ret_20 <= max(0.8, atr_percent * 1.2)) + ) + dangerous = ( + label in NEGATIVE_LONG_PATTERNS + or llm_regime in {"downtrend", "breakdown", "panic"} + or bool(llm.get("block_entry", False)) + ) + active = bool( + settings.grid_trading_enabled + and range_like + and not dangerous + and spread_ok + and liquidity_ok + and volatility_ok + and width > 0 + ) + buy_zone = bool(active and range_position <= _clamp(settings.grid_buy_zone, 0.05, 0.95)) + reason = ( + f"диапазон {range_position:.2f}, ширина {range_width_percent:.2f}%" + if active + else "условия grid-режима не подтверждены" + ) + return { + "enabled": settings.grid_trading_enabled, + "active": active, + "buy_zone": buy_zone, + "range_position": round(range_position, 4), + "range_width_percent": round(range_width_percent, 4), + "buy_zone_limit": round(_clamp(settings.grid_buy_zone, 0.05, 0.95), 4), + "llm_grid_suitable": llm_grid, + "range_like": range_like, + "dangerous": dangerous, + "reason": reason, + } + + +def _rebound_state( + *, + settings: Settings, + candles: list[Candle], + latest: Candle, + previous: Candle, + pattern: dict, + llm: dict, + spread_ok: bool, + liquidity_ok: bool, + volume_ok: bool, + volatility_ok: bool, + atr_percent: float, +) -> dict: + metrics = pattern.get("metrics") or {} + ret_3 = _safe_float( + metrics.get("ret_3_percent"), + _percent_change(latest.close, candles[-4].close) if len(candles) >= 4 else 0.0, + ) + ret_10 = _safe_float( + metrics.get("ret_10_percent"), + _percent_change(latest.close, candles[-11].close) if len(candles) >= 11 else 0.0, + ) + ret_20 = _safe_float( + metrics.get("ret_20_percent"), + _percent_change(latest.close, candles[-21].close) if len(candles) >= 21 else 0.0, + ) + label = str(pattern.get("label") or "").lower() + tags = {str(tag).lower() for tag in pattern.get("tags", [])} + llm_regime = str(llm.get("market_regime", "")).lower() + rsi = _safe_float(latest.rsi_14, 50.0) + previous_rsi = _safe_float(previous.rsi_14, rsi) + volume_ratio = latest.volume / latest.volume_ma_20 if latest.volume_ma_20 and latest.volume_ma_20 > 0 else 0.0 + body = abs(latest.close - latest.open) + lower_wick = max(0.0, min(latest.open, latest.close) - latest.low) + low6 = min(candle.low for candle in candles[-6:]) + high6 = max(candle.high for candle in candles[-6:]) + recent_lows = [candle.low for candle in candles[-6:-1]] + no_new_low = bool(recent_lows) and latest.low >= min(recent_lows) * 0.999 + bounce_from_low = ((latest.close - low6) / latest.close * 100) if latest.close else 0.0 + range_width = max(high6 - low6, latest.close * 0.0001) + range_position = _clamp((latest.close - low6) / range_width, 0.0, 1.0) + recent_drop_depth = max(abs(min(ret_10, 0.0)), abs(min(ret_20, 0.0)) * 0.65) + pattern_down = ( + label in NEGATIVE_LONG_PATTERNS + or any(tag in NEGATIVE_LONG_PATTERNS for tag in tags) + or any(marker in label for marker in ("нисход", "пад", "пробой вниз", "ускор")) + ) + price_drop = ret_10 <= -max(0.35, atr_percent * 1.1) or ret_20 <= -max(0.6, atr_percent * 1.6) + recent_drop = bool(price_drop and (pattern_down or ret_10 < 0 or ret_20 < 0)) + body_base = max(body, latest.close * 0.0001) + wick_absorption = lower_wick >= body_base * 0.6 + bounced = bounce_from_low >= max(0.08, atr_percent * 0.3) or range_position >= 0.18 + momentum_stabilized = latest.close >= previous.close or abs(ret_3) <= max(0.25, atr_percent * 0.8) or no_new_low + rsi_zone = 24 <= rsi <= 52 + rsi_improving = rsi >= previous_rsi or rsi <= 38 + market_ok = spread_ok and liquidity_ok and volatility_ok + continuing_collapse = bool( + latest.close < previous.close + and not no_new_low + and ret_3 <= -max(0.6, atr_percent * 1.2) + and rsi < 34 + ) + panic_regime = llm_regime in {"panic", "breakdown"} + + drop_score = _clamp(recent_drop_depth / max(0.45, atr_percent * 2.0), 0.0, 1.0) + stabilization_score = 1.0 if latest.close >= previous.close else 0.75 if no_new_low else 0.55 if momentum_stabilized else 0.0 + absorption_score = _clamp(max(lower_wick / (body_base * 1.4), bounce_from_low / max(0.08, atr_percent * 0.7)), 0.0, 1.0) + rsi_score = 1.0 if rsi_zone and rsi_improving else 0.65 if rsi_zone else 0.0 + volume_score = _clamp(volume_ratio / 1.2, 0.0, 1.0) + market_score = 1.0 if market_ok else 0.0 + probability = ( + drop_score * 0.22 + + stabilization_score * 0.24 + + absorption_score * 0.20 + + rsi_score * 0.18 + + volume_score * 0.08 + + market_score * 0.08 + ) + if continuing_collapse or panic_regime: + probability = min(probability, 0.45) + if not recent_drop: + probability = min(probability, 0.50) + if not market_ok: + probability = min(probability, 0.55) + min_probability = _clamp(settings.rebound_min_probability, 0.45, 0.9) + active = bool( + settings.rebound_trading_enabled + and recent_drop + and momentum_stabilized + and (wick_absorption or bounced) + and rsi_zone + and market_ok + and volume_ok + and not continuing_collapse + and not panic_regime + and probability >= min_probability + ) + return { + "enabled": settings.rebound_trading_enabled, + "active": active, + "probability": round(_clamp(probability, 0.0, 1.0), 4), + "entry_score": round(_clamp(probability, 0.0, 1.0), 4) if active else 0.0, + "min_probability": round(min_probability, 4), + "recent_drop": recent_drop, + "momentum_stabilized": momentum_stabilized, + "wick_absorption": wick_absorption, + "bounced_from_low": bounced, + "rsi_zone": rsi_zone, + "rsi_improving": rsi_improving, + "market_ok": market_ok, + "volume_ratio": round(volume_ratio, 4), + "ret_3_percent": round(ret_3, 4), + "ret_10_percent": round(ret_10, 4), + "ret_20_percent": round(ret_20, 4), + "bounce_from_low_percent": round(bounce_from_low, 4), + "range_position_6": round(range_position, 4), + "continuing_collapse": continuing_collapse, + "panic_regime": panic_regime, + "reason": ( + "падение замедлилось, есть признаки короткого отскока" + if active + else "rebound-сигнал не подтвержден" + ), + } + + +def _safe_float(value: object, default: float = 0.0) -> float: + try: + return float(value) + except (TypeError, ValueError): + return default + + +def _percent_change(current: float, previous: float) -> float: + return ((current - previous) / previous * 100) if previous else 0.0 + + +def _adaptive_rules(learning: dict | None) -> dict: + learning = learning or {} + rules = learning.get("adaptive_rules", learning) + return dict(rules) if isinstance(rules, dict) else {} + + +def _adaptive_threshold_adjustment(adaptive: dict) -> float: + raw = adaptive.get("effective_entry_threshold_adjustment", adaptive.get("entry_threshold_adjustment", 0.0)) + return _clamp(_safe_float(raw, 0.0), -0.18, 0.18) + + +def _adaptive_blocks_entry(adaptive: dict, falling_market: bool = False, rebound_confirmed: bool = False) -> bool: + if adaptive.get("allow_new_entries") is False: + return True + if adaptive.get("over_target_exposure"): + return True + if adaptive.get("symbol_blocked") or adaptive.get("pattern_blocked"): + return True + if adaptive.get("bad_market_entry_block") and falling_market and not rebound_confirmed: + return True + return False + + +def _adaptive_block_reason(adaptive: dict, falling_market: bool = False, rebound_confirmed: bool = False) -> str: + if adaptive.get("allow_new_entries") is False: + return "новые входы выключены режимом обучения" + if adaptive.get("over_target_exposure"): + return "экспозиция выше цели обучения" + if adaptive.get("symbol_blocked"): + return "символ в стоп-листе обучения" + if adaptive.get("pattern_blocked"): + return "шаблон в стоп-листе обучения" + if adaptive.get("bad_market_entry_block") and falling_market and not rebound_confirmed: + return "падающий рынок, добор запрещен" + return "адаптивное правило" + + +def _falling_market(latest: Candle, previous: Candle, pattern_label: str, llm: dict) -> bool: + label = pattern_label.lower() + llm_regime = str(llm.get("market_regime", "")).lower() + ema_down = ( + latest.ema_20 is not None + and latest.ema_50 is not None + and latest.close < latest.ema_50 + and latest.ema_20 < latest.ema_50 + ) + momentum_down = latest.close < previous.close and (latest.rsi_14 is None or latest.rsi_14 < 50) + pattern_down = any(marker in label for marker in ("нисход", "пад", "пробой вниз", "ускор")) + llm_down = llm_regime in {"downtrend", "breakdown", "panic"} + return bool(ema_down or (momentum_down and pattern_down) or llm_down) + + +def _adaptive_percent(adaptive: dict, key: str, default: float, low: float, high: float) -> float: + return _clamp(_safe_float(adaptive.get(key), default), low, high) + + +def _estimated_exit_net_percent(position: Position, price: float, settings: Settings) -> float: + if position.entry_price <= 0: + return 0.0 + gross_percent = ((price - position.entry_price) / position.entry_price) * 100 + round_trip_cost_percent = (settings.taker_fee_rate * 2 + settings.slippage_rate * 2) * 100 + return gross_percent - round_trip_cost_percent + + +def _adaptive_indicator_exit_allowed(adaptive: dict, mode_key: str, estimated_exit_net_percent: float) -> bool: + mode = str(adaptive.get(mode_key, "normal")).lower() + if mode != "profit_only": + return True + min_exit_profit = _safe_float(adaptive.get("min_exit_profit_percent"), 0.0) + return estimated_exit_net_percent >= min_exit_profit + + +def _forecast_exit_signal( + *, + forecast: dict, + position: Position, + price: float, + estimated_exit_net_percent: float, + stop_loss_percent: float, + min_edge_percent: float, +) -> tuple[str, float, str] | None: + if not forecast.get("usable"): + return None + skill = _safe_float(forecast.get("skill"), 0.0) + expected_return = _safe_float(forecast.get("expected_return_percent"), 0.0) + probability_up = _safe_float(forecast.get("probability_up"), 0.5) + min_edge = max(0.0, min_edge_percent) + strong_negative = skill > 0.02 and expected_return <= -max(min_edge, 0.03) and probability_up <= 0.44 + if not strong_negative: + return None + reason = forecast.get("reason") or "ожидается снижение" + if estimated_exit_net_percent >= 0: + return "SELL", 0.82, f"прогноз временного ряда ухудшился: {reason}; фиксируем результат" + loss_from_entry = ((price - position.entry_price) / position.entry_price) if position.entry_price else 0.0 + soft_loss_limit = -max(0.003, stop_loss_percent * 0.35) + if loss_from_entry <= soft_loss_limit: + return "SELL", 0.84, f"прогноз временного ряда ухудшился: {reason}; ограничиваем убыток до stop-loss" + return None + + +def _learning_blocks_entry( + *, + learning: dict, + learning_adjustment: float, + min_samples: int, + max_adjustment: float, + enabled: bool, +) -> bool: + if not enabled: + return False + sample_size = int(learning.get("sample_size", 0) or 0) + net_pnl = float(learning.get("net_pnl", 0.0) or 0.0) + win_rate = float(learning.get("win_rate", 0.0) or 0.0) + strong_negative_adjustment = -max(0.06, max_adjustment * 0.65) + return ( + sample_size >= min_samples + and net_pnl < 0 + and win_rate <= 0.25 + and learning_adjustment <= strong_negative_adjustment + ) diff --git a/crypto_spot_bot/time_series.py b/crypto_spot_bot/time_series.py new file mode 100644 index 0000000..2cbcdad --- /dev/null +++ b/crypto_spot_bot/time_series.py @@ -0,0 +1,503 @@ +from __future__ import annotations + +import json +import math +from dataclasses import asdict, dataclass, field +from functools import lru_cache +from typing import Any + +from crypto_spot_bot.config import Settings +from crypto_spot_bot.models import Candle + + +@dataclass(slots=True) +class TimeSeriesForecast: + enabled: bool + usable: bool + model: str + volatility_model: str + expected_return_percent: float + expected_price: float + volatility_percent: float + probability_up: float + confidence_adjustment: float + block_entry: bool + validation_mae_percent: float + baseline_mae_percent: float + skill: float + horizon: int + reason: str + candidates: list[dict[str, Any]] = field(default_factory=list) + + def as_dict(self) -> dict[str, Any]: + return asdict(self) + + +class TimeSeriesForecaster: + def __init__(self, settings: Settings): + self.settings = settings + self._lstm_artifact_mtime: float | None = None + self._lstm_artifact: dict[str, Any] = {} + + def forecast(self, candles: list[Candle], symbol: str | None = None) -> TimeSeriesForecast: + if not self.settings.time_series_forecast_enabled: + return _empty_forecast(False, "прогноз временных рядов выключен") + closes = [float(candle.close) for candle in candles if candle.close > 0] + min_candles = max(30, self.settings.time_series_min_candles) + if len(closes) < min_candles: + return _empty_forecast(True, "недостаточно свечей для прогноза") + returns = _log_returns(closes) + if len(returns) < 20: + return _empty_forecast(True, "недостаточно доходностей для прогноза") + + validation_window = min( + max(8, self.settings.time_series_validation_window), + max(8, len(returns) // 3), + ) + lstm_artifact = self._load_lstm_artifact() + candidates = _validate_candidates(returns, validation_window, self.settings, symbol, lstm_artifact) + best = min(candidates, key=lambda item: item["mae"]) + baseline = next(item for item in candidates if item["model"] == "naive") + latest_prediction = _predict_next_return(best["model"], returns, self.settings, symbol, lstm_artifact) + horizon = max(1, self.settings.time_series_forecast_horizon) + expected_return = latest_prediction * horizon + expected_price = closes[-1] * math.exp(expected_return) + + ewma_vol = _ewma_volatility(returns, self.settings.time_series_ewma_lambda) + garch_vol = _fixed_garch_volatility(returns) + vol_one_step = max(ewma_vol, garch_vol) + volatility_percent = vol_one_step * math.sqrt(horizon) * 100 + expected_return_percent = (math.exp(expected_return) - 1) * 100 + probability_up = _normal_cdf(expected_return / max(vol_one_step * math.sqrt(horizon), 1e-9)) + baseline_mae = float(baseline["mae"]) + model_mae = float(best["mae"]) + skill = (baseline_mae - model_mae) / baseline_mae if baseline_mae > 0 else 0.0 + skill = _clamp(skill, -1.0, 1.0) + min_edge = max(0.0, self.settings.time_series_min_edge_percent) + usable_skill = skill > 0.02 and best["model"] != "naive" + confidence_adjustment = _confidence_adjustment( + expected_return_percent=expected_return_percent, + probability_up=probability_up, + skill=skill, + min_edge=min_edge, + max_adjustment=self.settings.time_series_max_adjustment, + usable_skill=usable_skill, + ) + block_entry = bool( + usable_skill + and expected_return_percent <= -min_edge + and probability_up <= 0.45 + ) + reason = _reason( + model=best["model"], + expected_return_percent=expected_return_percent, + probability_up=probability_up, + skill=skill, + block_entry=block_entry, + usable_skill=usable_skill, + ) + return TimeSeriesForecast( + enabled=True, + usable=True, + model=best["model"], + volatility_model="max(EWMA,GARCH-like)", + expected_return_percent=round(expected_return_percent, 4), + expected_price=round(expected_price, 8), + volatility_percent=round(volatility_percent, 4), + probability_up=round(probability_up, 4), + confidence_adjustment=round(confidence_adjustment, 4), + block_entry=block_entry, + validation_mae_percent=round(model_mae * 100, 4), + baseline_mae_percent=round(baseline_mae * 100, 4), + skill=round(skill, 4), + horizon=horizon, + reason=reason, + candidates=[ + {"model": item["model"], "mae_percent": round(float(item["mae"]) * 100, 4)} + for item in sorted(candidates, key=lambda item: item["mae"]) + ], + ) + + def _load_lstm_artifact(self) -> dict[str, Any]: + if not self.settings.time_series_lstm_enabled: + return {} + path = self.settings.time_series_lstm_model_path + try: + stat = path.stat() + except OSError: + self._lstm_artifact_mtime = None + self._lstm_artifact = {} + return {} + if self._lstm_artifact_mtime == stat.st_mtime: + return self._lstm_artifact + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + data = {} + self._lstm_artifact = data if isinstance(data, dict) else {} + self._lstm_artifact_mtime = stat.st_mtime + return self._lstm_artifact + + +def _empty_forecast(enabled: bool, reason: str) -> TimeSeriesForecast: + return TimeSeriesForecast( + enabled=enabled, + usable=False, + model="none", + volatility_model="none", + expected_return_percent=0.0, + expected_price=0.0, + volatility_percent=0.0, + probability_up=0.5, + confidence_adjustment=0.0, + block_entry=False, + validation_mae_percent=0.0, + baseline_mae_percent=0.0, + skill=0.0, + horizon=0, + reason=reason, + ) + + +def _log_returns(closes: list[float]) -> list[float]: + return [math.log(closes[index] / closes[index - 1]) for index in range(1, len(closes))] + + +def _validate_candidates( + returns: list[float], + validation_window: int, + settings: Settings, + symbol: str | None = None, + lstm_artifact: dict[str, Any] | None = None, +) -> list[dict[str, float | str]]: + models = ["naive", "drift", "ewma", "ar1", "ar3"] + if _can_use_lstm(returns, settings, symbol, lstm_artifact or {}): + models.append("lstm") + rows: list[dict[str, float | str]] = [] + start = max(8, len(returns) - validation_window) + for model in models: + errors: list[float] = [] + for index in range(start, len(returns)): + history = returns[:index] + if len(history) < 8: + continue + predicted = _predict_next_return(model, history, settings, symbol, lstm_artifact) + errors.append(abs(predicted - returns[index])) + mae = sum(errors) / len(errors) if errors else 1e9 + rows.append({"model": model, "mae": mae}) + return rows + + +def _predict_next_return( + model: str, + returns: list[float], + settings: Settings | None = None, + symbol: str | None = None, + lstm_artifact: dict[str, Any] | None = None, +) -> float: + if model == "naive": + return 0.0 + if model == "drift": + window = returns[-24:] if len(returns) >= 24 else returns + return sum(window) / len(window) if window else 0.0 + if model == "ewma": + return _ewma_mean(returns, 0.82) + if model == "ar1": + return _ar_predict(returns, 1) + if model == "ar3": + return _ar_predict(returns, 3) + if model == "lstm" and settings is not None: + return _lstm_predict(returns, settings, symbol, lstm_artifact or {}) + return 0.0 + + +def _ewma_mean(values: list[float], decay: float) -> float: + if not values: + return 0.0 + estimate = values[0] + alpha = 1 - _clamp(decay, 0.01, 0.99) + for value in values[1:]: + estimate = alpha * value + (1 - alpha) * estimate + return estimate + + +def _ar_predict(returns: list[float], lag_count: int) -> float: + if len(returns) <= lag_count + 6: + return _predict_next_return("drift", returns) + rows: list[list[float]] = [] + targets: list[float] = [] + for index in range(lag_count, len(returns)): + rows.append([1.0] + [returns[index - lag] for lag in range(1, lag_count + 1)]) + targets.append(returns[index]) + coeffs = _ols(rows, targets) + if not coeffs: + return _predict_next_return("drift", returns) + features = [1.0] + [returns[-lag] for lag in range(1, lag_count + 1)] + prediction = sum(coeff * feature for coeff, feature in zip(coeffs, features)) + recent_abs = sorted(abs(value) for value in returns[-48:]) if len(returns) >= 8 else [0.01] + cap = max(recent_abs[int(len(recent_abs) * 0.9)], 0.0002) + return _clamp(prediction, -cap, cap) + + +def _can_use_lstm( + returns: list[float], + settings: Settings, + symbol: str | None, + lstm_artifact: dict[str, Any], +) -> bool: + if not settings.time_series_lstm_enabled: + return False + params = _lstm_params(settings, symbol, lstm_artifact) + return len(returns) >= params["lookback"] + 16 + + +def _lstm_params(settings: Settings, symbol: str | None, lstm_artifact: dict[str, Any]) -> dict[str, float | int]: + params: dict[str, float | int] = { + "lookback": settings.time_series_lstm_lookback, + "units": settings.time_series_lstm_units, + "ridge": settings.time_series_lstm_ridge, + } + default_params = lstm_artifact.get("default") + if isinstance(default_params, dict): + params.update(_clean_lstm_params(default_params)) + symbols = lstm_artifact.get("symbols") + symbol_params = symbols.get(symbol.upper()) if symbol and isinstance(symbols, dict) else None + if isinstance(symbol_params, dict): + params.update(_clean_lstm_params(symbol_params)) + return { + "lookback": int(_clamp(float(params["lookback"]), 6.0, 128.0)), + "units": int(_clamp(float(params["units"]), 2.0, 16.0)), + "ridge": _clamp(float(params["ridge"]), 1e-8, 0.5), + } + + +def _clean_lstm_params(data: dict[str, Any]) -> dict[str, float | int]: + clean: dict[str, float | int] = {} + for key in ("lookback", "units", "ridge"): + value = data.get(key) + if isinstance(value, (int, float)): + clean[key] = value + elif isinstance(value, str): + try: + clean[key] = float(value) + except ValueError: + continue + return clean + + +def _lstm_predict( + returns: list[float], + settings: Settings, + symbol: str | None, + lstm_artifact: dict[str, Any], +) -> float: + params = _lstm_params(settings, symbol, lstm_artifact) + lookback = int(params["lookback"]) + units = int(params["units"]) + ridge = float(params["ridge"]) + if len(returns) <= lookback + 8: + return _predict_next_return("drift", returns) + + scale = _return_scale(returns) + normalized = [_clamp(value / scale, -6.0, 6.0) for value in returns] + states = _lstm_states(normalized, units) + rows: list[list[float]] = [] + targets: list[float] = [] + for index in range(lookback, len(returns)): + rows.append([1.0] + states[index - 1]) + targets.append(normalized[index]) + coeffs = _ols(rows, targets, ridge) + if not coeffs: + return _predict_next_return("drift", returns) + + features = [1.0] + states[-1] + prediction = sum(coeff * feature for coeff, feature in zip(coeffs, features)) + prediction = _clamp(prediction, -4.0, 4.0) * scale + recent_abs = sorted(abs(value) for value in returns[-48:]) if len(returns) >= 8 else [0.01] + cap = max(recent_abs[int(len(recent_abs) * 0.9)], 0.0002) + return _clamp(prediction, -cap, cap) + + +def _return_scale(returns: list[float]) -> float: + recent = returns[-120:] if len(returns) > 120 else returns + values = sorted(abs(value) for value in recent if math.isfinite(value)) + if not values: + return 0.0005 + median = values[len(values) // 2] + mean = sum(values) / len(values) + return max(max(median, mean * 0.5), 1e-5) + + +def _lstm_states(normalized_returns: list[float], units: int) -> list[list[float]]: + weights = _lstm_weights(units) + hidden = [0.0 for _ in range(units)] + cell = [0.0 for _ in range(units)] + states: list[list[float]] = [] + for value in normalized_returns: + hidden, cell = _lstm_step(value, hidden, cell, weights) + states.append(hidden[:]) + return states + + +@lru_cache(maxsize=16) +def _lstm_weights(units: int) -> tuple[list[list[float]], list[list[list[float]]], list[list[float]]]: + input_weights: list[list[float]] = [] + recurrent_weights: list[list[list[float]]] = [] + biases: list[list[float]] = [] + base_biases = (-0.15, 0.70, 0.05, 0.0) + for gate in range(4): + gate_input: list[float] = [] + gate_recurrent: list[list[float]] = [] + gate_bias: list[float] = [] + for unit in range(units): + gate_input.append(0.55 * math.sin((gate + 1) * (unit + 1) * 1.61803398875)) + gate_recurrent.append( + [ + 0.14 * math.sin((gate + 3) * (unit + 1) * (source + 1) * 0.731) + for source in range(units) + ] + ) + gate_bias.append(base_biases[gate] + 0.03 * math.sin((gate + 1) * (unit + 1))) + input_weights.append(gate_input) + recurrent_weights.append(gate_recurrent) + biases.append(gate_bias) + return input_weights, recurrent_weights, biases + + +def _lstm_step( + value: float, + hidden: list[float], + cell: list[float], + weights: tuple[list[list[float]], list[list[list[float]]], list[list[float]]], +) -> tuple[list[float], list[float]]: + input_weights, recurrent_weights, biases = weights + units = len(hidden) + next_hidden = [0.0 for _ in range(units)] + next_cell = [0.0 for _ in range(units)] + for unit in range(units): + gate_values = [] + for gate in range(4): + raw = input_weights[gate][unit] * value + biases[gate][unit] + raw += sum(recurrent_weights[gate][unit][source] * hidden[source] for source in range(units)) + gate_values.append(raw) + input_gate = _sigmoid(gate_values[0]) + forget_gate = _sigmoid(gate_values[1]) + output_gate = _sigmoid(gate_values[2]) + candidate = math.tanh(gate_values[3]) + next_cell[unit] = forget_gate * cell[unit] + input_gate * candidate + next_hidden[unit] = output_gate * math.tanh(next_cell[unit]) + return next_hidden, next_cell + + +def _sigmoid(value: float) -> float: + if value >= 40: + return 1.0 + if value <= -40: + return 0.0 + return 1 / (1 + math.exp(-value)) + + +def _ols(rows: list[list[float]], targets: list[float], ridge: float = 1e-8) -> list[float] | None: + if not rows: + return None + columns = len(rows[0]) + xtx = [[0.0 for _ in range(columns)] for _ in range(columns)] + xty = [0.0 for _ in range(columns)] + for row, target in zip(rows, targets): + for i in range(columns): + xty[i] += row[i] * target + for j in range(columns): + xtx[i][j] += row[i] * row[j] + for i in range(columns): + xtx[i][i] += ridge + return _solve_linear_system(xtx, xty) + + +def _solve_linear_system(matrix: list[list[float]], vector: list[float]) -> list[float] | None: + size = len(vector) + augmented = [row[:] + [vector[index]] for index, row in enumerate(matrix)] + for col in range(size): + pivot = max(range(col, size), key=lambda row: abs(augmented[row][col])) + if abs(augmented[pivot][col]) < 1e-12: + return None + augmented[col], augmented[pivot] = augmented[pivot], augmented[col] + pivot_value = augmented[col][col] + for item in range(col, size + 1): + augmented[col][item] /= pivot_value + for row in range(size): + if row == col: + continue + factor = augmented[row][col] + for item in range(col, size + 1): + augmented[row][item] -= factor * augmented[col][item] + return [augmented[row][size] for row in range(size)] + + +def _ewma_volatility(returns: list[float], decay: float) -> float: + if not returns: + return 0.0 + decay = _clamp(decay, 0.80, 0.995) + variance = returns[0] * returns[0] + for value in returns[1:]: + variance = decay * variance + (1 - decay) * value * value + return math.sqrt(max(variance, 0.0)) + + +def _fixed_garch_volatility(returns: list[float]) -> float: + if not returns: + return 0.0 + long_variance = sum(value * value for value in returns) / len(returns) + alpha = 0.08 + beta = 0.90 + omega = max(1e-12, (1 - alpha - beta) * long_variance) + variance = long_variance + for value in returns: + variance = omega + alpha * value * value + beta * variance + return math.sqrt(max(variance, 0.0)) + + +def _confidence_adjustment( + *, + expected_return_percent: float, + probability_up: float, + skill: float, + min_edge: float, + max_adjustment: float, + usable_skill: bool, +) -> float: + if not usable_skill: + return 0.0 + edge = abs(expected_return_percent) - min_edge + if edge <= 0: + return 0.0 + direction = 1.0 if expected_return_percent > 0 and probability_up >= 0.55 else -1.0 + if direction < 0 and probability_up > 0.45: + return 0.0 + strength = _clamp(edge / max(min_edge, 0.05), 0.0, 1.0) + probability_strength = _clamp(abs(probability_up - 0.5) / 0.25, 0.0, 1.0) + skill_strength = _clamp(skill / 0.18, 0.0, 1.0) + return direction * _clamp(max_adjustment, 0.0, 0.18) * strength * probability_strength * skill_strength + + +def _reason( + *, + model: str, + expected_return_percent: float, + probability_up: float, + skill: float, + block_entry: bool, + usable_skill: bool, +) -> str: + if not usable_skill: + return f"модель {model} не лучше baseline на walk-forward проверке" + if block_entry: + return f"модель {model}: ожидаемое движение вниз {expected_return_percent:.3f}%, P(рост)={probability_up:.2f}" + return f"модель {model}: прогноз {expected_return_percent:.3f}%, P(рост)={probability_up:.2f}, skill={skill:.3f}" + + +def _normal_cdf(value: float) -> float: + return 0.5 * (1 + math.erf(value / math.sqrt(2))) + + +def _clamp(value: float, low: float, high: float) -> float: + return max(low, min(high, value)) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..68bd8c5 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,21 @@ +services: + tradebot: + build: . + container_name: crypto-spot-tradebot + env_file: + - .env + environment: + HOST: 0.0.0.0 + user: "1000:1000" + ports: + - "127.0.0.1:8787:8787" + volumes: + - ./.env:/app/.env + - ./runtime:/app/runtime + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8787/api/health', timeout=5).read()"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + restart: unless-stopped diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..353aa5d --- /dev/null +++ b/pytest.ini @@ -0,0 +1,4 @@ +[pytest] +testpaths = tests +pythonpath = . +addopts = --basetemp=.pytest_tmp diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f8ef106 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +requests==2.32.3 +websockets==14.1 +pytest==8.4.2 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..46d4a26 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from crypto_spot_bot.config import Settings + + +@pytest.fixture +def make_settings(): + def factory(tmp_path: Path, **overrides) -> Settings: + values = dict( + trading_mode="paper", + host="127.0.0.1", + port=8787, + bybit_testnet=False, + bybit_api_key="", + bybit_api_secret="", + starting_balance_usdt=100.0, + auto_select_symbols=True, + top_symbols_count=6, + symbols=(), + base_interval="1", + kline_limit=240, + loop_interval_seconds=5, + fast_trading_enabled=False, + fast_loop_interval_seconds=1.0, + fast_entry_cooldown_seconds=20, + max_entries_per_minute=12, + websocket_enabled=False, + min_signal_confidence=0.64, + max_spread_percent=0.18, + min_24h_turnover_usdt=1_000_000.0, + pattern_analysis_enabled=True, + pattern_score_weight=0.18, + learning_enabled=True, + learning_lookback_trades=120, + learning_min_samples=3, + learning_max_adjustment=0.12, + llm_advisor_enabled=False, + ollama_base_url="http://192.168.0.210:11434", + ollama_model="gemma4:e4b", + llm_advisor_min_interval_seconds=180, + llm_advisor_timeout_seconds=45, + llm_advisor_max_adjustment=0.06, + min_position_usdt=1.0, + max_position_usdt=20.0, + max_symbol_exposure_usdt=20.0, + max_total_exposure_usdt=80.0, + max_open_positions=6, + max_positions_per_symbol=1, + grid_trading_enabled=True, + grid_entry_confidence=0.58, + grid_buy_zone=0.45, + grid_max_position_usdt=8.0, + rebound_trading_enabled=True, + rebound_entry_confidence=0.58, + rebound_min_probability=0.58, + rebound_max_position_usdt=6.0, + time_series_forecast_enabled=True, + time_series_min_candles=120, + time_series_validation_window=30, + time_series_forecast_horizon=3, + time_series_ewma_lambda=0.94, + time_series_min_edge_percent=0.04, + time_series_max_adjustment=0.08, + time_series_lstm_enabled=True, + time_series_lstm_lookback=32, + time_series_lstm_units=6, + time_series_lstm_ridge=0.0001, + time_series_lstm_model_path=tmp_path / "lstm_forecaster.json", + stop_loss_percent=0.02, + take_profit_percent=0.035, + trailing_stop_percent=0.015, + min_hold_seconds=180, + entry_cooldown_seconds=180, + max_daily_drawdown_usdt=6.0, + min_cash_reserve_usdt=5.0, + taker_fee_rate=0.001, + slippage_rate=0.0003, + enable_live_trading=False, + live_trading_confirm="", + live_order_max_usdt=10.0, + database_path=tmp_path / "tradebot.sqlite3", + log_path=tmp_path / "tradebot.log", + env_file_path=tmp_path / ".env", + ) + values.update(overrides) + return Settings(**values) + + return factory diff --git a/tests/test_bybit.py b/tests/test_bybit.py new file mode 100644 index 0000000..00fbe19 --- /dev/null +++ b/tests/test_bybit.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from crypto_spot_bot.bybit import BybitClient, _looks_like_leveraged_token, _looks_like_stablecoin + + +def test_leveraged_token_filter() -> None: + assert _looks_like_leveraged_token("BTC3L") + assert _looks_like_leveraged_token("ETHDOWN") + assert not _looks_like_leveraged_token("BTC") + + +def test_stablecoin_filter() -> None: + assert _looks_like_stablecoin("USDC") + assert _looks_like_stablecoin("FDUSD") + assert not _looks_like_stablecoin("BTC") + + +def test_spot_instrument_uses_min_order_amt_and_base_precision(make_settings, tmp_path) -> None: + client = BybitClient(make_settings(tmp_path)) + client.public_get = lambda *_args, **_kwargs: { + "list": [ + { + "symbol": "BTCUSDT", + "baseCoin": "BTC", + "quoteCoin": "USDT", + "status": "Trading", + "priceFilter": {"tickSize": "0.01"}, + "lotSizeFilter": { + "basePrecision": "0.000001", + "minOrderQty": "0.000001", + "minOrderAmt": "5", + }, + } + ] + } + + instrument = client.instruments()["BTCUSDT"] + + assert instrument.qty_step == 0.000001 + assert instrument.min_notional_value == 5.0 + + +def test_live_spot_order_explicitly_disables_leverage(make_settings, tmp_path) -> None: + client = BybitClient(make_settings(tmp_path)) + captured = {} + + def fake_private_post(path, payload): + captured["path"] = path + captured["payload"] = payload + return {"orderId": "test"} + + client.private_post = fake_private_post + client.place_spot_market_order("BTCUSDT", "Buy", 10, "quoteCoin", "order-1") + + assert captured["path"] == "/v5/order/create" + assert captured["payload"]["category"] == "spot" + assert captured["payload"]["isLeverage"] == 0 + assert captured["payload"]["orderFilter"] == "Order" diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..0e5d3b8 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import pytest + +from crypto_spot_bot.config import load_settings + + +def test_live_mode_requires_explicit_unlock(tmp_path, monkeypatch) -> None: + for key in ( + "TRADING_MODE", + "ENABLE_LIVE_TRADING", + "LIVE_TRADING_CONFIRM", + "BYBIT_API_KEY", + "BYBIT_API_SECRET", + ): + monkeypatch.delenv(key, raising=False) + env_file = tmp_path / ".env" + env_file.write_text("TRADING_MODE=live\n", encoding="utf-8") + + with pytest.raises(ValueError): + load_settings(env_file) + + +def test_fast_trading_env_sets_effective_intervals(tmp_path, monkeypatch) -> None: + for key in ( + "TRADING_MODE", + "FAST_TRADING_ENABLED", + "FAST_LOOP_INTERVAL_SECONDS", + "FAST_ENTRY_COOLDOWN_SECONDS", + "MAX_ENTRIES_PER_MINUTE", + ): + monkeypatch.delenv(key, raising=False) + env_file = tmp_path / ".env" + env_file.write_text( + "\n".join( + [ + "TRADING_MODE=paper", + "FAST_TRADING_ENABLED=true", + "FAST_LOOP_INTERVAL_SECONDS=0.75", + "FAST_ENTRY_COOLDOWN_SECONDS=12", + "MAX_ENTRIES_PER_MINUTE=4", + ] + ), + encoding="utf-8", + ) + + settings = load_settings(env_file) + + assert settings.fast_trading_enabled is True + assert settings.effective_loop_interval_seconds == 0.75 + assert settings.effective_entry_cooldown_seconds == 12 + assert settings.max_entries_per_minute == 4 + + +def test_llm_advisor_is_disabled_by_default(tmp_path, monkeypatch) -> None: + monkeypatch.delenv("LLM_ADVISOR_ENABLED", raising=False) + monkeypatch.setenv("TRADING_MODE", "paper") + env_file = tmp_path / ".env" + env_file.write_text("TRADING_MODE=paper\n", encoding="utf-8") + + settings = load_settings(env_file) + + assert settings.llm_advisor_enabled is False diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py new file mode 100644 index 0000000..3cdbde8 --- /dev/null +++ b/tests/test_dashboard.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from crypto_spot_bot.dashboard import _apply_fast_trading +from crypto_spot_bot.storage import Storage + + +def test_apply_fast_trading_updates_runtime_and_env(make_settings, tmp_path) -> None: + settings = make_settings(tmp_path, fast_trading_enabled=False) + settings.env_file_path.write_text("FAST_TRADING_ENABLED=false\n", encoding="utf-8") + storage = Storage(settings.database_path) + + env_persisted = _apply_fast_trading(settings, storage, True) + + assert env_persisted is True + assert settings.fast_trading_enabled is True + assert storage.get_runtime("fast_trading_enabled") is True + assert "FAST_TRADING_ENABLED=true" in settings.env_file_path.read_text(encoding="utf-8") diff --git a/tests/test_execution.py b/tests/test_execution.py new file mode 100644 index 0000000..eab6753 --- /dev/null +++ b/tests/test_execution.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +from crypto_spot_bot.bybit import Instrument +from crypto_spot_bot.execution import PaperBroker +from crypto_spot_bot.models import Signal, Ticker +from crypto_spot_bot.storage import Storage + + +def test_paper_broker_buy_and_sell_records_trade(make_settings, tmp_path) -> None: + settings = make_settings(tmp_path) + storage = Storage(settings.database_path) + broker = PaperBroker(settings, storage) + ticker = Ticker("BTCUSDT", 100, 99.9, 100.1, 10_000_000, 100, 0) + instrument = Instrument("BTCUSDT", "BTC", "USDT", "Trading", 0.01, 0.000001, 0.000001, 5) + signal = Signal("BTCUSDT", "BUY", 0.8, "test") + + position = broker.buy(signal, ticker, instrument, {"BTCUSDT": 100}) + assert position is not None + assert broker.cash < settings.starting_balance_usdt + assert len(broker.open_positions()) == 1 + + trade = broker.sell(position, ticker, "test exit") + assert trade.side == "SELL" + assert len(broker.open_positions()) == 0 + assert storage.recent_trades(limit=10) + + +def test_paper_broker_limits_fast_entries_per_minute(make_settings, tmp_path) -> None: + settings = make_settings( + tmp_path, + max_entries_per_minute=1, + max_open_positions=3, + max_positions_per_symbol=3, + max_total_exposure_usdt=90, + ) + storage = Storage(settings.database_path) + broker = PaperBroker(settings, storage) + ticker = Ticker("BTCUSDT", 100, 99.9, 100.1, 10_000_000, 100, 0) + instrument = Instrument("BTCUSDT", "BTC", "USDT", "Trading", 0.01, 0.000001, 0.000001, 5) + + first = broker.buy(Signal("BTCUSDT", "BUY", 0.8, "first"), ticker, instrument, {"BTCUSDT": 100}) + second = broker.buy(Signal("BTCUSDT", "BUY", 0.8, "second"), ticker, instrument, {"BTCUSDT": 100}) + + assert first is not None + assert second is None + assert len(broker.open_positions()) == 1 + assert "лимит новых входов" in storage.recent_events(limit=1)[0]["message"] + + +def test_paper_broker_uses_signal_notional_and_pair_exposure(make_settings, tmp_path) -> None: + settings = make_settings( + tmp_path, + min_position_usdt=1, + max_position_usdt=20, + max_symbol_exposure_usdt=6, + max_total_exposure_usdt=50, + max_open_positions=20, + max_positions_per_symbol=1, + max_entries_per_minute=0, + ) + storage = Storage(settings.database_path) + broker = PaperBroker(settings, storage) + ticker = Ticker("BTCUSDT", 100, 99.9, 100.1, 10_000_000, 100, 0) + instrument = Instrument("BTCUSDT", "BTC", "USDT", "Trading", 0.01, 0.000001, 0.000001, 1) + + first = broker.buy( + Signal("BTCUSDT", "BUY", 0.8, "first", {"position_notional_usdt": 2}), + ticker, + instrument, + {"BTCUSDT": 100}, + ) + second = broker.buy( + Signal("BTCUSDT", "BUY", 0.8, "second", {"position_notional_usdt": 2}), + ticker, + instrument, + {"BTCUSDT": 100}, + ) + third = broker.buy( + Signal("BTCUSDT", "BUY", 0.8, "third", {"position_notional_usdt": 2}), + ticker, + instrument, + {"BTCUSDT": 100}, + ) + fourth = broker.buy( + Signal("BTCUSDT", "BUY", 0.8, "fourth", {"position_notional_usdt": 2}), + ticker, + instrument, + {"BTCUSDT": 100}, + ) + + assert first is not None + assert second is not None + assert third is not None + assert fourth is None + assert len(broker.open_positions()) == 3 + assert 5.5 <= broker.symbol_exposure("BTCUSDT") <= 6.0 + + +def test_paper_broker_respects_adaptive_exposure_target(make_settings, tmp_path) -> None: + settings = make_settings( + tmp_path, + min_position_usdt=1, + max_position_usdt=20, + max_symbol_exposure_usdt=20, + max_total_exposure_usdt=80, + max_open_positions=20, + max_positions_per_symbol=20, + max_entries_per_minute=0, + ) + storage = Storage(settings.database_path) + broker = PaperBroker(settings, storage) + ticker = Ticker("BTCUSDT", 100, 99.9, 100.1, 10_000_000, 100, 0) + instrument = Instrument("BTCUSDT", "BTC", "USDT", "Trading", 0.01, 0.000001, 0.000001, 1) + capped_signal = Signal( + "BTCUSDT", + "BUY", + 0.8, + "adaptive cap", + { + "position_notional_usdt": 10, + "adaptive_rules": { + "target_total_exposure_usdt": 0, + "target_symbol_exposure_usdt": 0, + }, + }, + ) + + position = broker.buy(capped_signal, ticker, instrument, {"BTCUSDT": 100}) + + assert position is None + assert broker.open_positions() == [] diff --git a/tests/test_indicators.py b/tests/test_indicators.py new file mode 100644 index 0000000..8f1e784 --- /dev/null +++ b/tests/test_indicators.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from crypto_spot_bot.indicators import add_indicators +from crypto_spot_bot.models import Candle + + +def test_add_indicators_populates_long_periods() -> None: + candles = [ + Candle( + timestamp=index, + open=100 + index * 0.1, + high=101 + index * 0.1, + low=99 + index * 0.1, + close=100 + index * 0.1, + volume=10 + index, + ) + for index in range(240) + ] + + add_indicators(candles) + + latest = candles[-1] + assert latest.ema_20 is not None + assert latest.ema_50 is not None + assert latest.ema_200 is not None + assert latest.rsi_14 is not None + assert latest.atr_14 is not None + assert latest.volume_ma_20 is not None diff --git a/tests/test_learning.py b/tests/test_learning.py new file mode 100644 index 0000000..7e09448 --- /dev/null +++ b/tests/test_learning.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +from crypto_spot_bot.learning import TradeLearner +from crypto_spot_bot.models import Trade, utc_now +from crypto_spot_bot.storage import Storage + + +def test_trade_learner_penalizes_losing_symbol_pattern(make_settings, tmp_path) -> None: + settings = make_settings(tmp_path, learning_min_samples=2) + storage = Storage(settings.database_path) + for value in (-0.4, -0.2): + storage.insert_trade( + Trade( + id=None, + symbol="BTCUSDT", + side="SELL", + qty=1, + entry_price=100, + exit_price=99, + net_pnl=value, + reason="test", + entry_pattern="пробой вниз", + entry_confidence=0.7, + opened_at=utc_now(), + closed_at=utc_now(), + ) + ) + + learner = TradeLearner(settings, storage) + learner.refresh() + adjustment = learner.adjustment_for("BTCUSDT", "пробой вниз") + + assert adjustment.sample_size >= 4 + assert adjustment.confidence_adjustment < 0 + assert "убыточными" in adjustment.reason + + +def test_trade_learner_builds_adaptive_rules_for_losing_ema_exit(make_settings, tmp_path) -> None: + settings = make_settings(tmp_path, learning_min_samples=3) + storage = Storage(settings.database_path) + for value in (-0.05, -0.04, -0.03): + storage.insert_trade( + Trade( + id=None, + symbol="BTCUSDT", + side="SELL", + qty=1, + entry_price=100, + exit_price=99.9, + net_pnl=value, + reason="краткосрочный тренд ослаб ниже EMA50", + entry_pattern="нейтрально", + entry_confidence=0.7, + opened_at=utc_now(), + closed_at=utc_now(), + ) + ) + + learner = TradeLearner(settings, storage) + state = learner.refresh() + rules = learner.rules_for("BTCUSDT", "нейтрально") + + assert state.adaptive_rules["risk_mode"] == "defensive" + assert rules["ema_exit_mode"] == "profit_only" + assert rules["effective_entry_threshold_adjustment"] > 0 + assert rules["min_hold_seconds"] > settings.min_hold_seconds + + +def test_trade_learner_enters_capital_protection_and_validates_rules(make_settings, tmp_path) -> None: + settings = make_settings(tmp_path, learning_min_samples=3, starting_balance_usdt=100, max_total_exposure_usdt=80) + storage = Storage(settings.database_path) + for value in (-0.12, -0.10, -0.08): + storage.insert_trade( + Trade( + id=None, + symbol="ETHUSDT", + side="SELL", + qty=1, + entry_price=100, + exit_price=99, + net_pnl=value, + reason="краткосрочный тренд ослаб ниже EMA50", + entry_pattern="нейтрально", + entry_confidence=0.7, + opened_at=utc_now(), + closed_at=utc_now(), + ) + ) + + learner = TradeLearner(settings, storage) + state = learner.refresh() + rules = state.adaptive_rules + + assert rules["trade_permission"] == "capital_protection" + assert rules["reduce_exposure"] is True + assert rules["bad_market_entry_block"] is True + assert rules["target_total_exposure_usdt"] == 35.0 + assert rules["validation"]["status"] == "accepted" + assert rules["validation"]["avoided_loss_usdt"] > 0 diff --git a/tests/test_llm_advisor.py b/tests/test_llm_advisor.py new file mode 100644 index 0000000..679b845 --- /dev/null +++ b/tests/test_llm_advisor.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from crypto_spot_bot.llm_advisor import LlmAdvisor, _extract_json +from crypto_spot_bot.storage import Storage + + +def test_extract_json_from_fenced_response() -> None: + data = _extract_json( + """ + ```json + {"market_regime":"range","risk_level":"low","confidence_adjustment":0.02} + ``` + """ + ) + + assert data["market_regime"] == "range" + assert data["confidence_adjustment"] == 0.02 + + +def test_llm_advisor_parse_clamps_adjustment(make_settings, tmp_path) -> None: + settings = make_settings(tmp_path, llm_advisor_max_adjustment=0.05) + advisor = LlmAdvisor(settings, Storage(settings.database_path)) + + advice = advisor._parse( + "BTCUSDT", + '{"market_regime":"breakout","risk_level":"high","confidence_adjustment":0.5,' + '"block_entry":true,"grid_suitable":false,"reason_ru":"тест"}', + ) + + assert advice.model == "gemma4:e4b" + assert advice.market_regime == "breakout" + assert advice.risk_level == "high" + assert advice.confidence_adjustment == 0.05 + assert advice.block_entry is True + + +def test_storage_records_llm_advice(make_settings, tmp_path) -> None: + settings = make_settings(tmp_path) + storage = Storage(settings.database_path) + + storage.insert_llm_advice( + symbol="BTCUSDT", + model="gemma4:e4b", + prompt_json={"symbol": "BTCUSDT"}, + response_text='{"confidence_adjustment":0}', + advice_json={"confidence_adjustment": 0.0, "reason_ru": "нейтрально"}, + ) + + items = storage.recent_llm_advice(limit=1) + assert items[0]["model"] == "gemma4:e4b" + assert items[0]["prompt"]["symbol"] == "BTCUSDT" + assert items[0]["advice"]["reason_ru"] == "нейтрально" diff --git a/tests/test_patterns.py b/tests/test_patterns.py new file mode 100644 index 0000000..76513fc --- /dev/null +++ b/tests/test_patterns.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from crypto_spot_bot.models import Candle +from crypto_spot_bot.patterns import PatternAnalyzer + + +def _candles_for_pullback() -> list[Candle]: + candles = [] + for index in range(40): + close = 100 + index * 0.2 + candles.append( + Candle( + timestamp=index, + open=close - 0.1, + high=close + 0.4, + low=close - 0.4, + close=close, + volume=100, + ema_20=close - 0.2, + ema_50=close - 1.0, + ema_200=close - 2.0, + rsi_14=48, + atr_14=1.0, + volume_ma_20=100, + ) + ) + latest = candles[-1] + latest.close = latest.ema_20 * 1.005 + latest.open = latest.close + 0.1 + return candles + + +def _candles_for_stabilized_drop() -> list[Candle]: + candles = [] + closes = [98.0, 97.3, 96.6, 95.9, 95.2, 94.8, 94.55, 94.42, 94.38, 94.40, 94.45, 94.56] + for index in range(40): + close = 101 - index * 0.08 + if index >= 28: + close = closes[index - 28] + rsi = 42 + if index >= 28: + rsi = 30 + max(0, index - 34) + candles.append( + Candle( + timestamp=index, + open=close - 0.12, + high=close + 0.25, + low=close - 0.26, + close=close, + volume=120, + ema_20=close + 0.35, + ema_50=close + 0.75, + ema_200=close + 1.4, + rsi_14=rsi, + atr_14=0.45, + volume_ma_20=100, + ) + ) + candles[-1].open = candles[-1].close - 0.20 + candles[-1].low = candles[-1].close - 0.26 + return candles + + +def test_pattern_analyzer_detects_trend_pullback() -> None: + result = PatternAnalyzer().analyze(_candles_for_pullback()) + + assert result.label == "трендовый откат" + assert result.score > 0.7 + assert "откат к средней" in result.tags + + +def test_pattern_analyzer_detects_stabilized_drop() -> None: + result = PatternAnalyzer().analyze(_candles_for_stabilized_drop()) + + assert result.label == "стабилизация после падения" + assert result.score >= 0.58 + assert "стабилизация после падения" in result.tags diff --git a/tests/test_strategy.py b/tests/test_strategy.py new file mode 100644 index 0000000..1ccbc33 --- /dev/null +++ b/tests/test_strategy.py @@ -0,0 +1,442 @@ +from __future__ import annotations + +from datetime import timedelta + +from crypto_spot_bot.models import Candle, Position, Ticker, utc_now +from crypto_spot_bot.patterns import PatternAnalyzer +from crypto_spot_bot.strategy import SpotStrategy + + +def _ready_candles() -> list[Candle]: + candles = [] + for index in range(205): + candle = Candle( + timestamp=index, + open=100, + high=103, + low=99, + close=101, + volume=100, + ema_20=100, + ema_50=99, + ema_200=98, + rsi_14=45, + atr_14=1.2, + volume_ma_20=90, + ) + candles.append(candle) + return candles + + +def _rebound_candles() -> list[Candle]: + candles = [] + tail = [98.0, 97.3, 96.6, 95.9, 95.2, 94.8, 94.55, 94.42, 94.38, 94.40, 94.45, 94.56] + for index in range(205): + close = 104 - index * 0.025 + if index >= 193: + close = tail[index - 193] + rsi = 45 + if index >= 193: + rsi = min(42, 29 + max(0, index - 198)) + candles.append( + Candle( + timestamp=index, + open=close - 0.12, + high=close + 0.25, + low=close - 0.26, + close=close, + volume=120, + ema_20=close + 0.35, + ema_50=close + 0.75, + ema_200=close + 1.4, + rsi_14=rsi, + atr_14=0.45, + volume_ma_20=100, + ) + ) + candles[-1].open = candles[-1].close - 0.20 + candles[-1].low = candles[-1].close - 0.26 + return candles + + +def test_strategy_emits_buy_when_score_passes_threshold(make_settings, tmp_path) -> None: + settings = make_settings(tmp_path) + strategy = SpotStrategy(settings) + ticker = Ticker( + symbol="BTCUSDT", + last_price=101, + bid=100.99, + ask=101.01, + turnover_24h=10_000_000, + volume_24h=1000, + change_24h=1.0, + ) + + signal = strategy.entry_signal("BTCUSDT", _ready_candles(), ticker, open_positions_for_symbol=0) + + assert signal.action == "BUY" + assert signal.confidence >= settings.min_signal_confidence + + +def test_strategy_blocks_negative_long_pattern(make_settings, tmp_path) -> None: + settings = make_settings(tmp_path) + strategy = SpotStrategy(settings) + ticker = Ticker( + symbol="BTCUSDT", + last_price=101, + bid=100.99, + ask=101.01, + turnover_24h=10_000_000, + volume_24h=1000, + change_24h=1.0, + ) + + signal = strategy.entry_signal( + "BTCUSDT", + _ready_candles(), + ticker, + open_positions_for_symbol=0, + pattern={"label": "нисходящий тренд", "score": 0.28}, + ) + + assert signal.action == "HOLD" + assert signal.diagnostics["entry_blocked_by_pattern"] is True + + +def test_strategy_blocks_strong_negative_learning(make_settings, tmp_path) -> None: + settings = make_settings(tmp_path) + strategy = SpotStrategy(settings) + ticker = Ticker( + symbol="BTCUSDT", + last_price=101, + bid=100.99, + ask=101.01, + turnover_24h=10_000_000, + volume_24h=1000, + change_24h=1.0, + ) + + signal = strategy.entry_signal( + "BTCUSDT", + _ready_candles(), + ticker, + open_positions_for_symbol=0, + pattern={"label": "нейтрально", "score": 0.5}, + learning={ + "sample_size": 10, + "net_pnl": -1.0, + "win_rate": 0.1, + "confidence_adjustment": -0.12, + "reason": "test", + }, + ) + + assert signal.action == "HOLD" + assert signal.diagnostics["entry_blocked_by_learning"] is True + + +def test_strategy_blocks_entry_when_llm_advisor_blocks(make_settings, tmp_path) -> None: + settings = make_settings(tmp_path, llm_advisor_enabled=True) + strategy = SpotStrategy(settings) + ticker = Ticker( + symbol="BTCUSDT", + last_price=101, + bid=100.99, + ask=101.01, + turnover_24h=10_000_000, + volume_24h=1000, + change_24h=1.0, + ) + + signal = strategy.entry_signal( + "BTCUSDT", + _ready_candles(), + ticker, + open_positions_for_symbol=0, + llm={ + "confidence_adjustment": -0.03, + "block_entry": True, + "reason_ru": "риск падения", + }, + ) + + assert signal.action == "HOLD" + assert signal.diagnostics["entry_blocked_by_llm"] is True + assert signal.diagnostics["llm_adjustment"] == -0.03 + + +def test_strategy_activates_grid_and_sets_position_size(make_settings, tmp_path) -> None: + settings = make_settings(tmp_path) + strategy = SpotStrategy(settings) + ticker = Ticker( + symbol="BTCUSDT", + last_price=101, + bid=100.99, + ask=101.01, + turnover_24h=10_000_000, + volume_24h=1000, + change_24h=0.1, + ) + + signal = strategy.entry_signal( + "BTCUSDT", + _ready_candles(), + ticker, + open_positions_for_symbol=2, + pattern={ + "label": "боковик", + "score": 0.48, + "tags": ["боковик"], + "metrics": {"high20": 105, "low20": 100, "ema_gap_percent": 0.1, "ret_20_percent": 0.2}, + }, + llm={"market_regime": "range", "grid_suitable": True, "risk_level": "medium"}, + ) + + assert signal.action == "BUY" + assert signal.diagnostics["trade_mode"] == "GRID" + assert signal.diagnostics["grid"]["active"] is True + assert 1 <= signal.diagnostics["position_notional_usdt"] <= settings.grid_max_position_usdt + + +def test_strategy_buys_probabilistic_rebound_after_stabilized_drop(make_settings, tmp_path) -> None: + settings = make_settings(tmp_path, rebound_entry_confidence=0.58, rebound_min_probability=0.58) + strategy = SpotStrategy(settings) + candles = _rebound_candles() + ticker = Ticker( + symbol="BTCUSDT", + last_price=candles[-1].close, + bid=candles[-1].close * 0.9999, + ask=candles[-1].close * 1.0001, + turnover_24h=10_000_000, + volume_24h=1000, + change_24h=-2.0, + ) + pattern = PatternAnalyzer().analyze(candles, ticker).as_dict() + + signal = strategy.entry_signal( + "BTCUSDT", + candles, + ticker, + open_positions_for_symbol=0, + pattern={**pattern, "label": "нисходящий тренд", "score": 0.28}, + ) + + assert signal.action == "BUY" + assert signal.diagnostics["trade_mode"] == "REBOUND" + assert signal.diagnostics["rebound"]["active"] is True + assert signal.diagnostics["entry_blocked_by_pattern"] is False + assert signal.diagnostics["position_notional_usdt"] <= settings.rebound_max_position_usdt + + +def test_strategy_rebound_does_not_override_llm_block(make_settings, tmp_path) -> None: + settings = make_settings( + tmp_path, + llm_advisor_enabled=True, + rebound_entry_confidence=0.58, + rebound_min_probability=0.58, + ) + strategy = SpotStrategy(settings) + candles = _rebound_candles() + ticker = Ticker( + symbol="BTCUSDT", + last_price=candles[-1].close, + bid=candles[-1].close * 0.9999, + ask=candles[-1].close * 1.0001, + turnover_24h=10_000_000, + volume_24h=1000, + change_24h=-2.0, + ) + + signal = strategy.entry_signal( + "BTCUSDT", + candles, + ticker, + open_positions_for_symbol=0, + pattern={"label": "нисходящий тренд", "score": 0.28}, + llm={"block_entry": True, "reason_ru": "риск продолжения падения"}, + ) + + assert signal.action == "HOLD" + assert signal.diagnostics["entry_blocked_by_llm"] is True + + +def test_strategy_trailing_stop_only_exits_after_profit(make_settings, tmp_path) -> None: + settings = make_settings(tmp_path) + strategy = SpotStrategy(settings) + candles = _ready_candles() + from crypto_spot_bot.models import Position + + position = Position( + id=1, + symbol="BTCUSDT", + qty=1, + entry_price=100, + notional_usdt=100, + entry_fee_usdt=0.1, + stop_loss=90, + take_profit=120, + highest_price=100.5, + ) + ticker = Ticker("BTCUSDT", 99.6, 99.5, 99.7, 1_000_000, 100, 0) + + signal = strategy.exit_signal(position, candles, ticker) + + assert signal.reason != "сработал trailing stop выше цены входа" + + +def test_strategy_adaptive_learning_holds_unprofitable_ema_exit(make_settings, tmp_path) -> None: + settings = make_settings(tmp_path, min_hold_seconds=60) + strategy = SpotStrategy(settings) + candles = _ready_candles() + candles[-2].close = 100.2 + candles[-1].close = 99.9 + candles[-1].ema_20 = 98.0 + candles[-1].ema_50 = 100.0 + position = Position( + id=1, + symbol="BTCUSDT", + qty=1, + entry_price=100, + notional_usdt=100, + entry_fee_usdt=0.1, + stop_loss=90, + take_profit=120, + highest_price=100.5, + opened_at=utc_now() - timedelta(seconds=600), + ) + ticker = Ticker("BTCUSDT", 99.9, 99.89, 99.91, 1_000_000, 100, 0) + + signal = strategy.exit_signal( + position, + candles, + ticker, + { + "adaptive_rules": { + "ema_exit_mode": "profit_only", + "min_exit_profit_percent": 0.31, + "min_hold_seconds": 60, + } + }, + ) + + assert signal.action == "HOLD" + assert "EMA50" in signal.reason + + +def test_strategy_blocks_entry_when_learning_exposure_target_exceeded(make_settings, tmp_path) -> None: + settings = make_settings(tmp_path) + strategy = SpotStrategy(settings) + ticker = Ticker("BTCUSDT", 101, 100.99, 101.01, 10_000_000, 1000, 1.0) + signal = strategy.entry_signal( + "BTCUSDT", + _ready_candles(), + ticker, + open_positions_for_symbol=1, + learning={ + "adaptive_rules": { + "over_target_exposure": True, + "target_total_exposure_usdt": 35, + "current_total_exposure_usdt": 80, + } + }, + ) + + assert signal.action == "HOLD" + assert signal.diagnostics["entry_blocked_by_adaptive_rules"] is True + assert signal.diagnostics["adaptive_block_reason"] == "экспозиция выше цели обучения" + + +def test_strategy_learning_reduce_now_sells_after_min_hold(make_settings, tmp_path) -> None: + settings = make_settings(tmp_path, min_hold_seconds=60) + strategy = SpotStrategy(settings) + position = Position( + id=7, + symbol="BTCUSDT", + qty=1, + entry_price=100, + notional_usdt=100, + entry_fee_usdt=0.1, + stop_loss=90, + take_profit=120, + highest_price=101, + opened_at=utc_now() - timedelta(seconds=600), + ) + ticker = Ticker("BTCUSDT", 99.5, 99.49, 99.51, 1_000_000, 100, 0) + + signal = strategy.exit_signal( + position, + _ready_candles(), + ticker, + {"adaptive_rules": {"reduce_exposure": True, "reduce_now": True, "min_hold_seconds": 60}}, + ) + + assert signal.action == "SELL" + assert "экспозицию" in signal.reason + + +def test_strategy_forecast_sells_to_lock_profit(make_settings, tmp_path) -> None: + settings = make_settings(tmp_path, min_hold_seconds=60) + strategy = SpotStrategy(settings) + position = Position( + id=9, + symbol="BTCUSDT", + qty=1, + entry_price=100, + notional_usdt=100, + entry_fee_usdt=0.1, + stop_loss=90, + take_profit=120, + highest_price=101.5, + opened_at=utc_now() - timedelta(seconds=600), + ) + ticker = Ticker("BTCUSDT", 101, 100.99, 101.01, 1_000_000, 100, 0) + + signal = strategy.exit_signal( + position, + _ready_candles(), + ticker, + forecast={ + "usable": True, + "skill": 0.2, + "expected_return_percent": -0.2, + "probability_up": 0.35, + "reason": "тестовый негативный прогноз", + }, + ) + + assert signal.action == "SELL" + assert "прогноз временного ряда" in signal.reason + + +def test_strategy_forecast_sells_to_limit_loss_before_stop(make_settings, tmp_path) -> None: + settings = make_settings(tmp_path, min_hold_seconds=60, stop_loss_percent=0.02) + strategy = SpotStrategy(settings) + position = Position( + id=10, + symbol="BTCUSDT", + qty=1, + entry_price=100, + notional_usdt=100, + entry_fee_usdt=0.1, + stop_loss=98, + take_profit=120, + highest_price=100.4, + opened_at=utc_now() - timedelta(seconds=600), + ) + ticker = Ticker("BTCUSDT", 99.2, 99.19, 99.21, 1_000_000, 100, 0) + + signal = strategy.exit_signal( + position, + _ready_candles(), + ticker, + forecast={ + "usable": True, + "skill": 0.2, + "expected_return_percent": -0.2, + "probability_up": 0.35, + "reason": "тестовый негативный прогноз", + }, + ) + + assert signal.action == "SELL" + assert "ограничиваем убыток" in signal.reason diff --git a/tests/test_time_series.py b/tests/test_time_series.py new file mode 100644 index 0000000..de5a01c --- /dev/null +++ b/tests/test_time_series.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +import json + +from crypto_spot_bot.models import Candle +from crypto_spot_bot.time_series import TimeSeriesForecaster + + +def _candles_from_returns(returns: list[float]) -> list[Candle]: + close = 100.0 + candles = [ + Candle( + timestamp=0, + open=close, + high=close * 1.001, + low=close * 0.999, + close=close, + volume=100, + ) + ] + for index, ret in enumerate(returns, start=1): + previous = close + close = close * (2.718281828459045 ** ret) + candles.append( + Candle( + timestamp=index, + open=previous, + high=max(previous, close) * 1.001, + low=min(previous, close) * 0.999, + close=close, + volume=100, + ) + ) + return candles + + +def test_time_series_forecaster_selects_positive_predictive_model(make_settings, tmp_path) -> None: + settings = make_settings( + tmp_path, + time_series_min_candles=80, + time_series_validation_window=24, + time_series_forecast_horizon=3, + ) + returns = [] + value = 0.0003 + for _ in range(140): + value = 0.00025 + value * 0.55 + returns.append(value) + + forecast = TimeSeriesForecaster(settings).forecast(_candles_from_returns(returns)) + + assert forecast.usable is True + assert forecast.model != "naive" + assert forecast.expected_return_percent > 0 + assert forecast.probability_up > 0.5 + + +def test_time_series_forecaster_blocks_negative_edge(make_settings, tmp_path) -> None: + settings = make_settings( + tmp_path, + time_series_min_candles=80, + time_series_validation_window=24, + time_series_forecast_horizon=3, + time_series_min_edge_percent=0.03, + ) + returns = [] + value = -0.0003 + for _ in range(140): + value = -0.00025 + value * 0.55 + returns.append(value) + + forecast = TimeSeriesForecaster(settings).forecast(_candles_from_returns(returns)) + + assert forecast.usable is True + assert forecast.expected_return_percent < 0 + assert forecast.block_entry is True + + +def test_time_series_forecaster_includes_lstm_candidate(make_settings, tmp_path) -> None: + settings = make_settings( + tmp_path, + time_series_min_candles=80, + time_series_validation_window=20, + time_series_lstm_enabled=True, + time_series_lstm_lookback=12, + time_series_lstm_units=4, + ) + returns = [] + for index in range(140): + seasonal = 0.00018 if index % 5 in {0, 1, 2} else -0.00011 + returns.append(seasonal + 0.00002 * ((index % 7) - 3)) + + forecast = TimeSeriesForecaster(settings).forecast(_candles_from_returns(returns), symbol="BTCUSDT") + + assert forecast.usable is True + assert any(candidate["model"] == "lstm" for candidate in forecast.candidates) + + +def test_time_series_forecaster_reads_lstm_artifact(make_settings, tmp_path) -> None: + artifact_path = tmp_path / "lstm_forecaster.json" + artifact_path.write_text( + json.dumps( + { + "version": 1, + "symbols": { + "BTCUSDT": {"lookback": 10, "units": 3, "ridge": 0.01}, + }, + } + ), + encoding="utf-8", + ) + settings = make_settings( + tmp_path, + time_series_min_candles=80, + time_series_validation_window=20, + time_series_lstm_enabled=True, + time_series_lstm_model_path=artifact_path, + ) + returns = [0.00012 if index % 3 else -0.00008 for index in range(140)] + + forecast = TimeSeriesForecaster(settings).forecast(_candles_from_returns(returns), symbol="BTCUSDT") + + assert forecast.usable is True + assert any(candidate["model"] == "lstm" for candidate in forecast.candidates) diff --git a/tools/train_lstm_forecaster.py b/tools/train_lstm_forecaster.py new file mode 100644 index 0000000..13595ca --- /dev/null +++ b/tools/train_lstm_forecaster.py @@ -0,0 +1,144 @@ +from __future__ import annotations + +import argparse +import json +import sys +from dataclasses import replace +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from crypto_spot_bot.bybit import BybitClient +from crypto_spot_bot.config import Settings, load_settings +from crypto_spot_bot.time_series import _log_returns, _validate_candidates + + +def main() -> None: + args = _parse_args() + settings = load_settings(args.env) + client = BybitClient(settings) + symbols = _symbols(args.symbols, settings, client) + interval = args.interval or settings.base_interval + output = Path(args.output) if args.output else settings.time_series_lstm_model_path + + artifact: dict[str, Any] = { + "version": 1, + "type": "lstm_reservoir_ridge_params", + "created_at": datetime.now(timezone.utc).isoformat(), + "interval": interval, + "limit": args.limit, + "symbols": {}, + } + + for symbol in symbols: + result = _train_symbol( + client=client, + settings=settings, + symbol=symbol, + interval=interval, + limit=args.limit, + lookbacks=_ints(args.lookbacks), + units_values=_ints(args.units), + ridges=_floats(args.ridges), + ) + if result is None: + print(f"{symbol}: skipped, not enough candles or returns") + continue + artifact["symbols"][symbol] = result + print( + f"{symbol}: lookback={result['lookback']} units={result['units']} " + f"ridge={result['ridge']} mae={result['validation_mae_percent']:.5f}% " + f"baseline={result['baseline_mae_percent']:.5f}% skill={result['skill']:.4f}" + ) + + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(json.dumps(artifact, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + print(f"saved {output}") + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Train lightweight LSTM forecast params on Bybit spot candles.") + parser.add_argument("--env", default=None, help="Path to .env file.") + parser.add_argument("--symbols", default="", help="Comma-separated symbols. Defaults to configured or popular spot pairs.") + parser.add_argument("--interval", default="", help="Bybit kline interval. Defaults to BASE_INTERVAL.") + parser.add_argument("--limit", type=int, default=1000, help="Kline limit per symbol.") + parser.add_argument("--lookbacks", default="16,32", help="Comma-separated LSTM lookback candidates.") + parser.add_argument("--units", default="4,6", help="Comma-separated LSTM unit candidates.") + parser.add_argument("--ridges", default="0.001", help="Comma-separated ridge candidates.") + parser.add_argument("--output", default="", help="Output JSON path. Defaults to TIME_SERIES_LSTM_MODEL_PATH.") + return parser.parse_args() + + +def _symbols(raw: str, settings: Settings, client: BybitClient) -> list[str]: + if raw.strip(): + return [item.strip().upper() for item in raw.split(",") if item.strip()] + if settings.symbols: + return list(settings.symbols) + return client.popular_spot_symbols(settings.top_symbols_count) + + +def _train_symbol( + *, + client: BybitClient, + settings: Settings, + symbol: str, + interval: str, + limit: int, + lookbacks: list[int], + units_values: list[int], + ridges: list[float], +) -> dict[str, Any] | None: + candles = client.klines(symbol, interval, limit) + closes = [float(candle.close) for candle in candles if candle.close > 0] + returns = _log_returns(closes) + if len(returns) < 80: + return None + validation_window = min(max(8, settings.time_series_validation_window), max(8, len(returns) // 3)) + best: dict[str, Any] | None = None + for lookback in lookbacks: + for units in units_values: + for ridge in ridges: + candidate_settings = replace( + settings, + time_series_lstm_enabled=True, + time_series_lstm_lookback=lookback, + time_series_lstm_units=units, + time_series_lstm_ridge=ridge, + ) + candidates = _validate_candidates(returns, validation_window, candidate_settings, symbol, {}) + baseline = next((item for item in candidates if item["model"] == "naive"), None) + lstm = next((item for item in candidates if item["model"] == "lstm"), None) + if baseline is None or lstm is None: + continue + baseline_mae = float(baseline["mae"]) + lstm_mae = float(lstm["mae"]) + skill = (baseline_mae - lstm_mae) / baseline_mae if baseline_mae > 0 else 0.0 + row = { + "lookback": lookback, + "units": units, + "ridge": ridge, + "validation_mae_percent": lstm_mae * 100, + "baseline_mae_percent": baseline_mae * 100, + "skill": skill, + "candles": len(candles), + "returns": len(returns), + } + if best is None or lstm_mae < best["validation_mae_percent"] / 100: + best = row + return best + + +def _ints(raw: str) -> list[int]: + return [int(item.strip()) for item in raw.split(",") if item.strip()] + + +def _floats(raw: str) -> list[float]: + return [float(item.strip()) for item in raw.split(",") if item.strip()] + + +if __name__ == "__main__": + main()