Initial TradeBot implementation
This commit is contained in:
@@ -0,0 +1,9 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.pytest_cache/
|
||||||
|
.pytest_tmp/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
.env
|
||||||
|
runtime/
|
||||||
|
.git/
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.pytest_cache/
|
||||||
|
.pytest_tmp/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
.env
|
||||||
|
runtime/
|
||||||
|
*.log
|
||||||
+13
@@ -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"]
|
||||||
@@ -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: <https://bybit-exchange.github.io/docs/v5/intro>.
|
||||||
|
|
||||||
|
Список инструментов Bybit Spot берется из `/v5/market/instruments-info`; документация Bybit описывает для Spot поля `baseCoin`, `quoteCoin`, `status`, `priceFilter`, `lotSizeFilter`, `basePrecision` и `minOrderAmt`, поэтому размеры paper/live-ордеров в коде валидируются по данным инструмента: <https://bybit-exchange.github.io/docs/v5/market/instrument>.
|
||||||
|
|
||||||
|
Популярность пар определяется через `/v5/market/tickers`, потому что Bybit Spot ticker возвращает `turnover24h`, `volume24h`, `bid1Price`, `ask1Price` и `lastPrice`: <https://bybit-exchange.github.io/docs/v5/market/tickers>.
|
||||||
|
|
||||||
|
Лучшие bid/ask берутся из `/v5/market/orderbook`; документация Bybit описывает `GET /v5/market/orderbook` с `category=spot`: <https://bybit-exchange.github.io/docs/v5/market/orderbook>.
|
||||||
|
|
||||||
|
WebSocket-стакан использует topic `orderbook.{depth}.{symbol}`; Bybit документирует snapshot/delta-поведение и частоты push для Spot depth 1/50/200/1000: <https://bybit-exchange.github.io/docs/v5/websocket/public/orderbook>.
|
||||||
|
|
||||||
|
Live market orders используют `/v5/order/create`; Bybit документирует для Spot `orderType=Market`, `side`, `qty`, `category=spot`, а для market buy по умолчанию qty может быть в quote currency через `marketUnit=quoteCoin`: <https://bybit-exchange.github.io/docs/v5/order/create-order>.
|
||||||
|
|
||||||
|
Функции уровня коммерческих automated trading systems взяты из проверяемых источников:
|
||||||
|
|
||||||
|
- Investopedia перечисляет важные свойства algo trading software: real-time market data, low latency, configurability, backtesting, broker/exchange integration, fees/costs и APIs: <https://www.investopedia.com/articles/active-trading/090815/picking-right-algorithmic-trading-software.asp>.
|
||||||
|
- Investopedia отдельно указывает, что automated trading systems задают правила entry/exit/money management, но требуют мониторинга и несут риск mechanical failures и over-optimization: <https://www.investopedia.com/articles/trading/11/automated-trading-systems.asp>.
|
||||||
|
- QuantInsti описывает типовой путь разработки: стратегия, backtesting, paper trading, затем live trading, плюс GUI, order management и risk management: <https://www.quantinsti.com/articles/automated-trading-system/>.
|
||||||
|
- Документация `statsmodels` описывает ARIMA как общий интерфейс для AR/MA/ARMA/ARIMA/SARIMA-моделей; в боте используется легкий AR(1)/AR(3) вариант без добавления тяжелой зависимости `statsmodels`: <https://www.statsmodels.org/stable/generated/statsmodels.tsa.arima.model.ARIMA.html>.
|
||||||
|
- Документация `arch` описывает GARCH(p,q) как модель для прогнозирования волатильности; в боте используется фиксированная GARCH(1,1)-подобная рекурсия без MLE-оценки параметров, чтобы сохранить легкий runtime на Raspberry Pi: <https://arch.readthedocs.io/en/stable/univariate/univariate_volatility_forecasting.html>.
|
||||||
|
- RiskMetrics описывает EWMA-подход к оценке волатильности через коэффициент затухания; в боте `TIME_SERIES_EWMA_LAMBDA=0.94` используется как настраиваемое значение по умолчанию: <https://www.msci.com/documents/10199/d0905614-2771-46dc-b000-1a033146586a>.
|
||||||
|
- Hochreiter и Schmidhuber описали LSTM как recurrent neural network architecture для последовательностей; в боте используется легкая LSTM-reservoir рекурсия с ridge-readout, а не полноценное PyTorch/TensorFlow обучение внутри Docker: <https://direct.mit.edu/neco/article/9/8/1735/6109/Long-Short-Term-Memory>.
|
||||||
|
|
||||||
|
Я не могу подтвердить, что эта стратегия будет прибыльной. Источники выше описывают технические свойства и риски автоматической торговли, но не гарантируют прибыль.
|
||||||
|
|
||||||
|
## Быстрый старт локально
|
||||||
|
|
||||||
|
```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: <http://127.0.0.1:8787/>
|
||||||
|
|
||||||
|
## Локальное обучение 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://<host>: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
|
||||||
|
```
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""Crypto spot trading bot package."""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
@@ -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()
|
||||||
@@ -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",
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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}
|
||||||
@@ -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
|
||||||
@@ -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))
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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))
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
[pytest]
|
||||||
|
testpaths = tests
|
||||||
|
pythonpath = .
|
||||||
|
addopts = --basetemp=.pytest_tmp
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
fastapi==0.115.6
|
||||||
|
uvicorn[standard]==0.34.0
|
||||||
|
requests==2.32.3
|
||||||
|
websockets==14.1
|
||||||
|
pytest==8.4.2
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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")
|
||||||
@@ -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() == []
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"] == "нейтрально"
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user