Initial TradeBot implementation

This commit is contained in:
Курнат Андрей
2026-06-20 19:22:59 +03:00
commit de9de755f5
37 changed files with 6840 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
__pycache__/
*.py[cod]
.pytest_cache/
.pytest_tmp/
.venv/
venv/
.env
runtime/
.git/
+73
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
* text=auto eol=lf
+9
View File
@@ -0,0 +1,9 @@
__pycache__/
*.py[cod]
.pytest_cache/
.pytest_tmp/
.venv/
venv/
.env
runtime/
*.log
+13
View File
@@ -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"]
+183
View File
@@ -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
```
+3
View File
@@ -0,0 +1,3 @@
"""Crypto spot trading bot package."""
__version__ = "0.1.0"
+268
View File
@@ -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()
+231
View File
@@ -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",
}
+267
View File
@@ -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
+354
View File
@@ -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}
+106
View File
@@ -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
+407
View File
@@ -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))
+226
View File
@@ -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()
+27
View File
@@ -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()
+224
View File
@@ -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
],
}
+151
View File
@@ -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
+229
View File
@@ -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)
+360
View File
@@ -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)
+839
View File
@@ -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
)
+503
View File
@@ -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))
+21
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
[pytest]
testpaths = tests
pythonpath = .
addopts = --basetemp=.pytest_tmp
+5
View File
@@ -0,0 +1,5 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
requests==2.32.3
websockets==14.1
pytest==8.4.2
+92
View File
@@ -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
+58
View File
@@ -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"
+63
View File
@@ -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
+17
View File
@@ -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")
+131
View File
@@ -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() == []
+28
View File
@@ -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
+99
View File
@@ -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
+52
View File
@@ -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"] == "нейтрально"
+77
View File
@@ -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
+442
View File
@@ -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
+124
View File
@@ -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)
+144
View File
@@ -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()