From 8a211acf98de678d6de3c0fc9220f2381ae469dd Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 21 Jun 2026 09:15:38 +0300 Subject: [PATCH] Add trend MACD spot strategy --- .env.example | 37 ++-- README.md | 57 ++++--- crypto_spot_bot/bot.py | 22 ++- crypto_spot_bot/bybit.py | 4 +- crypto_spot_bot/config.py | 44 +++-- crypto_spot_bot/dashboard.py | 28 ++- crypto_spot_bot/execution.py | 2 + crypto_spot_bot/indicators.py | 34 ++++ crypto_spot_bot/market_data.py | 13 +- crypto_spot_bot/models.py | 3 + crypto_spot_bot/strategy.py | 199 ++++++++++++++++++++++ tests/conftest.py | 9 +- tests/test_bybit.py | 9 +- tests/test_config.py | 9 +- tests/test_execution.py | 25 +++ tests/test_indicators.py | 3 + tests/test_strategy.py | 174 +++++++++++++++++++ tools/install_windows_torch_retrainer.ps1 | 2 +- 18 files changed, 593 insertions(+), 81 deletions(-) diff --git a/.env.example b/.env.example index 41ad5fb..f1ea280 100644 --- a/.env.example +++ b/.env.example @@ -8,11 +8,14 @@ BYBIT_API_SECRET= STARTING_BALANCE_USDT=100 AUTO_SELECT_SYMBOLS=false -TOP_SYMBOLS_COUNT=6 -SYMBOLS=BTCUSDT,ETHUSDT,HYPEUSDT,SOLUSDT,LTCUSDT,XRPUSDT +TOP_SYMBOLS_COUNT=3 +SYMBOLS=BTCUSDT,ETHUSDT,SOLUSDT -BASE_INTERVAL=1 +STRATEGY_MODE=trend_macd +BASE_INTERVAL=60 KLINE_LIMIT=240 +TREND_INTERVAL=D +TREND_KLINE_LIMIT=260 LOOP_INTERVAL_SECONDS=5 FAST_TRADING_ENABLED=false FAST_LOOP_INTERVAL_SECONDS=1 @@ -22,38 +25,42 @@ WEBSOCKET_ENABLED=true MIN_SIGNAL_CONFIDENCE=0.64 MAX_SPREAD_PERCENT=0.18 MIN_24H_TURNOVER_USDT=1000000 -PATTERN_ANALYSIS_ENABLED=true +PATTERN_ANALYSIS_ENABLED=false PATTERN_SCORE_WEIGHT=0.18 -LEARNING_ENABLED=true +LEARNING_ENABLED=false LEARNING_LOOKBACK_TRADES=120 LEARNING_MIN_SAMPLES=3 LEARNING_MAX_ADJUSTMENT=0.12 LEARNING_MAX_POSITION_MULTIPLIER=1.6 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 +MAX_POSITION_USDT=25 +MAX_SYMBOL_EXPOSURE_USDT=25 +MAX_TOTAL_EXPOSURE_USDT=75 +MAX_OPEN_POSITIONS=3 +MAX_POSITIONS_PER_SYMBOL=1 +GRID_TRADING_ENABLED=false GRID_ENTRY_CONFIDENCE=0.58 GRID_BUY_ZONE=0.45 GRID_MAX_POSITION_USDT=8 -REBOUND_TRADING_ENABLED=true +REBOUND_TRADING_ENABLED=false REBOUND_ENTRY_CONFIDENCE=0.58 REBOUND_MIN_PROBABILITY=0.58 REBOUND_MAX_POSITION_USDT=6 -KELLY_SIZING_ENABLED=true +KELLY_SIZING_ENABLED=false KELLY_FRACTION=0.25 KELLY_MAX_FRACTION=0.20 -TIME_SERIES_FORECAST_ENABLED=true +RISK_PER_TRADE_PERCENT=0.01 +ATR_TRAILING_MULTIPLIER=2.2 +TREND_RSI_MIN=45 +TREND_RSI_MAX=65 +TIME_SERIES_FORECAST_ENABLED=false TIME_SERIES_MIN_CANDLES=120 TIME_SERIES_FORECAST_HORIZON=3 TIME_SERIES_MIN_EDGE_PERCENT=0.04 TIME_SERIES_MAX_ADJUSTMENT=0.08 TIME_SERIES_LSTM_ENABLED=true TIME_SERIES_LSTM_MODEL_PATH=runtime/lstm_forecaster.json -STOP_LOSS_PERCENT=0.02 +STOP_LOSS_PERCENT=0.04 TAKE_PROFIT_PERCENT=0.035 TRAILING_STOP_PERCENT=0.015 MIN_HOLD_SECONDS=180 diff --git a/README.md b/README.md index e3228a8..809b980 100644 --- a/README.md +++ b/README.md @@ -5,18 +5,16 @@ Spot-бот для демо-торговли криптовалютой на р ## Что реализовано - Реальные market data Bybit Spot: REST bootstrap и WebSocket-обновления. -- Фиксированный набор USDT spot-пар: `BTCUSDT`, `ETHUSDT`, `HYPEUSDT`, `SOLUSDT`, `LTCUSDT`, `XRPUSDT`. +- Фиксированный набор USDT spot-пар для основной стратегии: `BTCUSDT`, `ETHUSDT`, `SOLUSDT`. - Paper trading с учетом cash, комиссий, проскальзывания, stop-loss, take-profit и trailing stop. - Spot-only логика: покупка базовой монеты за USDT и продажа обратно, без short и без плеча. - Live spot-ордеры явно отправляются без плеча: `category=spot`, `isLeverage=0`. -- Анализ шаблонов рынка: трендовый откат, пробой вверх/вниз, ускоренное падение, боковик, перепроданность с разворотом и объемный всплеск. -- Обучение на закрытых сделках: статистика PnL и win rate блокирует плохие пары/паттерны, а положительное матожидание масштабирует Kelly-размер входа. -- LLM Advisor выключен по умолчанию; стратегия, обучение, grid и rebound работают без запросов к Ollama. -- Динамический размер позиции: стратегия считает вход через fractional Kelly по вероятности прогноза, stop/take и издержкам, затем ограничивает сумму через `MIN_POSITION_USDT`..`MAX_POSITION_USDT` и лимиты экспозиции. -- Автоматический grid-режим: бот включает grid-входы на боковике, покупает только в нижней части диапазона и выключает grid при падающих/опасных режимах. -- Вероятностный rebound-вход: после снижения бот отдельно оценивает стабилизацию, отскок от локального low, RSI, объем и рыночные ограничения; такой вход ограничен меньшим размером позиции. -- Прогнозирование временных рядов: только экспортированная PyTorch `LSTM/GRU`-модель для ожидаемой доходности и оценки неопределенности по validation MAE. Встроенные не-torch fallback-прогнозы удалены; если валидного torch-артефакта нет, прогноз для пары недоступен. -- Защитные блокировки входа: явно отрицательные LONG-шаблоны и setups с сильной отрицательной статистикой обучения запрещают новые покупки. +- Основная стратегия `trend_macd`: вход на `1h`, дневной фильтр тренда на `1d`, long только если цена выше дневной EMA200 и дневная EMA50 выше EMA200. +- Вход `trend_macd`: MACD на `1h` пересекает signal вверх, цена выше EMA50, RSI в диапазоне `45..65`, спред и ликвидность проходят runtime-фильтры. +- Выход `trend_macd`: MACD пересекает signal вниз, `1h` свеча закрылась ниже EMA50, сработал стоп `4%` или ATR trailing stop `2.2 ATR`. +- Риск `trend_macd`: размер позиции считается как `equity * RISK_PER_TRADE_PERCENT / STOP_LOSS_PERCENT`, по умолчанию риск не выше `1%` депозита на сделку. +- DCA/мартингейл отключены: в режиме `trend_macd` брокер не разрешает вторую позицию по той же паре. +- Grid, rebound, adaptive learning, Kelly sizing и time-series forecast выключены по умолчанию и не участвуют в принятии решений `trend_macd`. - Быстрый режим торговли: отдельный короткий интервал цикла, короткий cooldown после выхода и лимит новых входов в минуту; выходы по риску этим лимитом не блокируются. - Веб-dashboard на русском: equity, cash, PnL, позиции, сделки, сигналы, события, свечные графики, переключатель быстрой торговли и индикаторы работы обучения. - SQLite runtime-хранилище в `runtime/tradebot.sqlite3`. @@ -74,7 +72,7 @@ Dashboard: --epochs 60 ``` -Файл из `TIME_SERIES_LSTM_MODEL_PATH` читается ботом автоматически. Экспортированные модели появляются в dashboard как `PyTorch LSTM` или `PyTorch GRU`; старый легкий reservoir LSTM-кандидат и все встроенные не-torch прогнозы удалены. Если валидной PyTorch модели для пары нет, бот не подставляет fallback-прогноз. +Файл из `TIME_SERIES_LSTM_MODEL_PATH` читается ботом автоматически, если `TIME_SERIES_FORECAST_ENABLED=true`. В основной стратегии `trend_macd` прогноз выключен по умолчанию и не влияет на входы/выходы. Экспортированные модели появляются в dashboard как `PyTorch LSTM` или `PyTorch GRU`; старый легкий reservoir LSTM-кандидат и все встроенные не-torch прогнозы удалены. Автопереобучение на Windows запускает PyTorch trainer, пишет лог в `runtime/torch_retrain.log` и защищается от параллельных запусков: @@ -83,7 +81,7 @@ powershell -ExecutionPolicy Bypass -File tools\run_torch_retrain.ps1 powershell -ExecutionPolicy Bypass -File tools\install_windows_torch_retrainer.ps1 ``` -По умолчанию Windows-расписание переобучает PyTorch `LSTM/GRU` каждые 6 часов с `--limit 1000` на фиксированных парах `BTCUSDT,ETHUSDT,HYPEUSDT,SOLUSDT,LTCUSDT,XRPUSDT`. Параметры можно переопределить через env: `TORCH_RETRAIN_SYMBOLS`, `TORCH_RETRAIN_LIMIT`, `TORCH_RETRAIN_LOOKBACKS`, `TORCH_RETRAIN_ARCHITECTURES`, `TORCH_RETRAIN_HIDDEN_SIZES`, `TORCH_RETRAIN_LAYERS`, `TORCH_RETRAIN_DROPOUTS`, `TORCH_RETRAIN_EPOCHS`, `TORCH_RETRAIN_PATIENCE`, `TORCH_RETRAIN_INTERVAL`, `TORCH_RETRAIN_ENV`. +По умолчанию Windows-расписание переобучает PyTorch `LSTM/GRU` каждые 6 часов с `--limit 1000` на парах `BTCUSDT,ETHUSDT,SOLUSDT`. Параметры можно переопределить через env: `TORCH_RETRAIN_SYMBOLS`, `TORCH_RETRAIN_LIMIT`, `TORCH_RETRAIN_LOOKBACKS`, `TORCH_RETRAIN_ARCHITECTURES`, `TORCH_RETRAIN_HIDDEN_SIZES`, `TORCH_RETRAIN_LAYERS`, `TORCH_RETRAIN_DROPOUTS`, `TORCH_RETRAIN_EPOCHS`, `TORCH_RETRAIN_PATIENCE`, `TORCH_RETRAIN_INTERVAL`, `TORCH_RETRAIN_ENV`. ## Docker @@ -103,9 +101,12 @@ Dashboard: `http://:8787/` TRADING_MODE=paper STARTING_BALANCE_USDT=100 AUTO_SELECT_SYMBOLS=false -TOP_SYMBOLS_COUNT=6 -SYMBOLS=BTCUSDT,ETHUSDT,HYPEUSDT,SOLUSDT,LTCUSDT,XRPUSDT -BASE_INTERVAL=1 +TOP_SYMBOLS_COUNT=3 +SYMBOLS=BTCUSDT,ETHUSDT,SOLUSDT +STRATEGY_MODE=trend_macd +BASE_INTERVAL=60 +TREND_INTERVAL=D +TREND_KLINE_LIMIT=260 LOOP_INTERVAL_SECONDS=5 FAST_TRADING_ENABLED=false FAST_LOOP_INTERVAL_SECONDS=1 @@ -113,38 +114,42 @@ FAST_ENTRY_COOLDOWN_SECONDS=20 MAX_ENTRIES_PER_MINUTE=12 WEBSOCKET_ENABLED=true MIN_SIGNAL_CONFIDENCE=0.64 -PATTERN_ANALYSIS_ENABLED=true +PATTERN_ANALYSIS_ENABLED=false PATTERN_SCORE_WEIGHT=0.18 -LEARNING_ENABLED=true +LEARNING_ENABLED=false LEARNING_LOOKBACK_TRADES=120 LEARNING_MIN_SAMPLES=3 LEARNING_MAX_ADJUSTMENT=0.12 LEARNING_MAX_POSITION_MULTIPLIER=1.6 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 +MAX_POSITION_USDT=25 +MAX_SYMBOL_EXPOSURE_USDT=25 +MAX_TOTAL_EXPOSURE_USDT=75 +MAX_OPEN_POSITIONS=3 +MAX_POSITIONS_PER_SYMBOL=1 +GRID_TRADING_ENABLED=false GRID_ENTRY_CONFIDENCE=0.58 GRID_BUY_ZONE=0.45 GRID_MAX_POSITION_USDT=8 -REBOUND_TRADING_ENABLED=true +REBOUND_TRADING_ENABLED=false REBOUND_ENTRY_CONFIDENCE=0.58 REBOUND_MIN_PROBABILITY=0.58 REBOUND_MAX_POSITION_USDT=6 -KELLY_SIZING_ENABLED=true +KELLY_SIZING_ENABLED=false KELLY_FRACTION=0.25 KELLY_MAX_FRACTION=0.20 -TIME_SERIES_FORECAST_ENABLED=true +RISK_PER_TRADE_PERCENT=0.01 +ATR_TRAILING_MULTIPLIER=2.2 +TREND_RSI_MIN=45 +TREND_RSI_MAX=65 +TIME_SERIES_FORECAST_ENABLED=false TIME_SERIES_MIN_CANDLES=120 TIME_SERIES_FORECAST_HORIZON=3 TIME_SERIES_MIN_EDGE_PERCENT=0.04 TIME_SERIES_MAX_ADJUSTMENT=0.08 TIME_SERIES_LSTM_ENABLED=true TIME_SERIES_LSTM_MODEL_PATH=runtime/lstm_forecaster.json -STOP_LOSS_PERCENT=0.02 +STOP_LOSS_PERCENT=0.04 TAKE_PROFIT_PERCENT=0.035 TRAILING_STOP_PERCENT=0.015 MIN_HOLD_SECONDS=180 diff --git a/crypto_spot_bot/bot.py b/crypto_spot_bot/bot.py index b1bee00..574acf5 100644 --- a/crypto_spot_bot/bot.py +++ b/crypto_spot_bot/bot.py @@ -141,6 +141,7 @@ class CryptoSpotBot: self._entry_cooldown_until.pop(symbol, None) ticker = self.market.tickers.get(symbol) candles = self.market.candles.get(symbol, []) + trend_candles = self.market.trend_candles.get(symbol, []) open_count = len(self.broker.positions_for_symbol(symbol)) pattern = self.market.patterns.get(symbol, {}) forecast = self.market.forecasts.get(symbol, {}) @@ -166,7 +167,18 @@ class CryptoSpotBot: account=account, ) ).as_dict() - signal = self.strategy.entry_signal(symbol, candles, ticker, open_count, pattern, learning, llm, forecast, account) + signal = self.strategy.entry_signal( + symbol, + candles, + ticker, + open_count, + pattern, + learning, + llm, + forecast, + account, + trend_candles, + ) self.storage.insert_signal(signal) if signal.action == "BUY" and ticker is not None: self.broker.buy( @@ -204,7 +216,7 @@ class CryptoSpotBot: return worst.id def _update_patterns(self) -> None: - if not self.settings.pattern_analysis_enabled: + if self.settings.strategy_mode == "trend_macd" or not self.settings.pattern_analysis_enabled: self.market.patterns = {} return patterns: dict[str, dict] = {} @@ -217,7 +229,11 @@ class CryptoSpotBot: self.market.patterns = patterns def _update_forecasts(self) -> None: - if self.forecaster is None or not self.settings.time_series_forecast_enabled: + if ( + self.settings.strategy_mode == "trend_macd" + or self.forecaster is None + or not self.settings.time_series_forecast_enabled + ): self.market.forecasts = {} return forecasts: dict[str, dict] = {} diff --git a/crypto_spot_bot/bybit.py b/crypto_spot_bot/bybit.py index b3143de..019e823 100644 --- a/crypto_spot_bot/bybit.py +++ b/crypto_spot_bot/bybit.py @@ -205,10 +205,10 @@ class BybitClient: return self.private_post("/v5/order/create", payload) -def websocket_subscribe_message(symbols: list[str]) -> str: +def websocket_subscribe_message(symbols: list[str], interval: str = "1") -> str: args: list[str] = [] for symbol in symbols: - args.extend([f"tickers.{symbol}", f"kline.1.{symbol}", f"orderbook.1.{symbol}"]) + args.extend([f"tickers.{symbol}", f"kline.{interval}.{symbol}", f"orderbook.1.{symbol}"]) return json.dumps({"op": "subscribe", "args": args}) diff --git a/crypto_spot_bot/config.py b/crypto_spot_bot/config.py index 91fe084..c32b333 100644 --- a/crypto_spot_bot/config.py +++ b/crypto_spot_bot/config.py @@ -5,7 +5,8 @@ from dataclasses import dataclass from pathlib import Path -FIXED_SPOT_SYMBOLS = ("BTCUSDT", "ETHUSDT", "HYPEUSDT", "SOLUSDT", "LTCUSDT", "XRPUSDT") +FIXED_SPOT_SYMBOLS = ("BTCUSDT", "ETHUSDT", "SOLUSDT") +STRATEGY_MODES = {"legacy", "trend_macd"} def _load_dotenv(path: Path) -> None: @@ -55,8 +56,11 @@ class Settings: auto_select_symbols: bool top_symbols_count: int symbols: tuple[str, ...] + strategy_mode: str base_interval: str kline_limit: int + trend_interval: str + trend_kline_limit: int loop_interval_seconds: int fast_trading_enabled: bool fast_loop_interval_seconds: float @@ -96,6 +100,10 @@ class Settings: kelly_sizing_enabled: bool kelly_fraction: float kelly_max_fraction: float + risk_per_trade_percent: float + atr_trailing_multiplier: float + trend_rsi_min: float + trend_rsi_max: float time_series_forecast_enabled: bool time_series_min_candles: int time_series_forecast_horizon: int @@ -166,6 +174,9 @@ def load_settings(env_file: str | Path | None = None) -> Settings: mode = os.getenv("TRADING_MODE", "paper").strip().lower() if mode not in {"paper", "live"}: raise ValueError("TRADING_MODE must be paper or live") + strategy_mode = os.getenv("STRATEGY_MODE", "trend_macd").strip().lower() + if strategy_mode not in STRATEGY_MODES: + raise ValueError("STRATEGY_MODE must be legacy or trend_macd") settings = Settings( trading_mode=mode, host=os.getenv("HOST", "127.0.0.1"), @@ -177,8 +188,11 @@ def load_settings(env_file: str | Path | None = None) -> Settings: auto_select_symbols=_bool_env("AUTO_SELECT_SYMBOLS", False), top_symbols_count=_int_env("TOP_SYMBOLS_COUNT", len(FIXED_SPOT_SYMBOLS)), symbols=_symbols_env("SYMBOLS") or FIXED_SPOT_SYMBOLS, - base_interval=os.getenv("BASE_INTERVAL", "1"), + strategy_mode=strategy_mode, + base_interval=os.getenv("BASE_INTERVAL", "60"), kline_limit=_int_env("KLINE_LIMIT", 240), + trend_interval=os.getenv("TREND_INTERVAL", "D"), + trend_kline_limit=_int_env("TREND_KLINE_LIMIT", 260), 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), @@ -188,9 +202,9 @@ def load_settings(env_file: str | Path | None = None) -> Settings: 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_analysis_enabled=_bool_env("PATTERN_ANALYSIS_ENABLED", False), pattern_score_weight=_float_env("PATTERN_SCORE_WEIGHT", 0.18), - learning_enabled=_bool_env("LEARNING_ENABLED", True), + learning_enabled=_bool_env("LEARNING_ENABLED", False), 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), @@ -202,30 +216,34 @@ def load_settings(env_file: str | Path | None = None) -> Settings: 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_position_usdt=_float_env("MAX_POSITION_USDT", 25.0), + max_symbol_exposure_usdt=_float_env("MAX_SYMBOL_EXPOSURE_USDT", 25.0), + max_total_exposure_usdt=_float_env("MAX_TOTAL_EXPOSURE_USDT", 75.0), + max_open_positions=_int_env("MAX_OPEN_POSITIONS", len(FIXED_SPOT_SYMBOLS)), max_positions_per_symbol=_int_env("MAX_POSITIONS_PER_SYMBOL", 1), - grid_trading_enabled=_bool_env("GRID_TRADING_ENABLED", True), + grid_trading_enabled=_bool_env("GRID_TRADING_ENABLED", False), 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_trading_enabled=_bool_env("REBOUND_TRADING_ENABLED", False), 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), - kelly_sizing_enabled=_bool_env("KELLY_SIZING_ENABLED", True), + kelly_sizing_enabled=_bool_env("KELLY_SIZING_ENABLED", False), kelly_fraction=_float_env("KELLY_FRACTION", 0.25), kelly_max_fraction=_float_env("KELLY_MAX_FRACTION", 0.20), - time_series_forecast_enabled=_bool_env("TIME_SERIES_FORECAST_ENABLED", True), + risk_per_trade_percent=_float_env("RISK_PER_TRADE_PERCENT", 0.01), + atr_trailing_multiplier=_float_env("ATR_TRAILING_MULTIPLIER", 2.2), + trend_rsi_min=_float_env("TREND_RSI_MIN", 45.0), + trend_rsi_max=_float_env("TREND_RSI_MAX", 65.0), + time_series_forecast_enabled=_bool_env("TIME_SERIES_FORECAST_ENABLED", False), time_series_min_candles=_int_env("TIME_SERIES_MIN_CANDLES", 120), time_series_forecast_horizon=_int_env("TIME_SERIES_FORECAST_HORIZON", 3), 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_model_path=Path(os.getenv("TIME_SERIES_LSTM_MODEL_PATH", "runtime/lstm_forecaster.json")), - stop_loss_percent=_float_env("STOP_LOSS_PERCENT", 0.02), + stop_loss_percent=_float_env("STOP_LOSS_PERCENT", 0.04), 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), diff --git a/crypto_spot_bot/dashboard.py b/crypto_spot_bot/dashboard.py index b79f8f6..a9c5009 100644 --- a/crypto_spot_bot/dashboard.py +++ b/crypto_spot_bot/dashboard.py @@ -176,8 +176,11 @@ def _safe_config(settings: Settings) -> dict[str, Any]: "auto_select_symbols": settings.auto_select_symbols, "top_symbols_count": settings.top_symbols_count, "symbols": settings.symbols, + "strategy_mode": settings.strategy_mode, "base_interval": settings.base_interval, "kline_limit": settings.kline_limit, + "trend_interval": settings.trend_interval, + "trend_kline_limit": settings.trend_kline_limit, "loop_interval_seconds": settings.loop_interval_seconds, "fast_trading_enabled": settings.fast_trading_enabled, "fast_loop_interval_seconds": settings.fast_loop_interval_seconds, @@ -213,6 +216,10 @@ def _safe_config(settings: Settings) -> dict[str, Any]: "kelly_sizing_enabled": settings.kelly_sizing_enabled, "kelly_fraction": settings.kelly_fraction, "kelly_max_fraction": settings.kelly_max_fraction, + "risk_per_trade_percent": settings.risk_per_trade_percent, + "atr_trailing_multiplier": settings.atr_trailing_multiplier, + "trend_rsi_min": settings.trend_rsi_min, + "trend_rsi_max": settings.trend_rsi_max, "time_series_forecast_enabled": settings.time_series_forecast_enabled, "time_series_min_candles": settings.time_series_min_candles, "time_series_forecast_horizon": settings.time_series_forecast_horizon, @@ -909,6 +916,7 @@ HTML = r""" function renderConfig(config) { const keys = [ ['Режим', modeName(config.trading_mode)], + ['Стратегия', config.strategy_mode || '-'], ['Стартовый баланс', money(config.starting_balance_usdt)], ['Мин. уверенность', config.min_signal_confidence], ['Быстрая торговля', yesNo(config.fast_trading_enabled)], @@ -923,25 +931,15 @@ HTML = r""" ['Мин. оборот 24ч', money(config.min_24h_turnover_usdt)], ['Размер позиции', `${money(config.min_position_usdt)} - ${money(config.max_position_usdt)}`], ['Лимит на пару', money(config.max_symbol_exposure_usdt)], - ['Grid-режим', yesNo(config.grid_trading_enabled)], - ['Grid порог / зона', `${num(config.grid_entry_confidence, 2)} / ${num((config.grid_buy_zone || 0) * 100, 0)}%`], - ['Grid макс. размер', money(config.grid_max_position_usdt)], - ['Rebound-режим', yesNo(config.rebound_trading_enabled)], - ['Rebound порог / вероятность', `${num(config.rebound_entry_confidence, 2)} / ${num(config.rebound_min_probability, 2)}`], - ['Rebound макс. размер', money(config.rebound_max_position_usdt)], - ['Kelly размер', `${yesNo(config.kelly_sizing_enabled)} · ${num(config.kelly_fraction, 2)}x · max ${num((config.kelly_max_fraction || 0) * 100, 1)}%`], - ['Прогноз временных рядов', yesNo(config.time_series_forecast_enabled)], - ['Модельный горизонт', `${config.time_series_forecast_horizon} свечи`], - ['Мин. edge прогноза', `${num(config.time_series_min_edge_percent, 3)}%`], - ['Нейропрогноз', modelArtifactSummary(config)], - ['Файл модели', config.time_series_lstm_model_path || '-'], + ['Риск на сделку', `${num((config.risk_per_trade_percent || 0) * 100, 2)}% equity`], + ['RSI входа', `${num(config.trend_rsi_min, 1)} - ${num(config.trend_rsi_max, 1)}`], ['Лимит в позициях', money(config.max_total_exposure_usdt)], ['Лимит позиций', `${config.max_open_positions} всего / ${config.max_positions_per_symbol} на пару`], - ['Стоп / цель', `${num(config.stop_loss_percent * 100, 2)}% / ${num(config.take_profit_percent * 100, 2)}%`], - ['Трейлинг-стоп', `${num(config.trailing_stop_percent * 100, 2)}%`], + ['Стоп', `${num(config.stop_loss_percent * 100, 2)}%`], + ['ATR trailing', `${num(config.atr_trailing_multiplier, 2)} ATR`], ['Удержание / пауза', `${config.min_hold_seconds}с / ${config.entry_cooldown_seconds}с`], ['Комиссия / проскальзывание', `${num(config.taker_fee_rate * 100, 3)}% / ${num(config.slippage_rate * 100, 3)}%`], - ['Таймфрейм', `${config.base_interval}м`], + ['Таймфрейм', `${config.base_interval} / тренд ${config.trend_interval}`], ['Поток данных', yesNo(config.websocket_enabled)] ]; document.getElementById('configGrid').innerHTML = keys.map(([k, v]) => ` diff --git a/crypto_spot_bot/execution.py b/crypto_spot_bot/execution.py index 223f0af..8c4fb48 100644 --- a/crypto_spot_bot/execution.py +++ b/crypto_spot_bot/execution.py @@ -92,6 +92,8 @@ class PaperBroker: return False, "достигнут лимит новых входов в минуту" if len(self.positions) >= self.settings.max_open_positions: return False, "достигнут общий лимит открытых позиций" + if self.settings.strategy_mode == "trend_macd" and len(self.positions_for_symbol(symbol)) >= 1: + return False, "DCA/усреднение отключено: позиция по паре уже открыта" 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)), diff --git a/crypto_spot_bot/indicators.py b/crypto_spot_bot/indicators.py index 083592a..36ff69c 100644 --- a/crypto_spot_bot/indicators.py +++ b/crypto_spot_bot/indicators.py @@ -15,6 +15,7 @@ def add_indicators(candles: list[Candle]) -> list[Candle]: ema200 = _ema(closes, 200) rsi14 = _rsi(closes, 14) atr14 = _atr(highs, lows, closes, 14) + macd, macd_signal, macd_hist = _macd(closes) volume_ma20 = _sma(volumes, 20) for index, candle in enumerate(candles): candle.ema_20 = ema20[index] @@ -22,6 +23,9 @@ def add_indicators(candles: list[Candle]) -> list[Candle]: candle.ema_200 = ema200[index] candle.rsi_14 = rsi14[index] candle.atr_14 = atr14[index] + candle.macd = macd[index] + candle.macd_signal = macd_signal[index] + candle.macd_hist = macd_hist[index] candle.volume_ma_20 = volume_ma20[index] return candles @@ -104,3 +108,33 @@ def _atr(highs: list[float], lows: list[float], closes: list[float], period: int atr = ((atr * (period - 1)) + true_ranges[index]) / period result[index] = atr return result + + +def _macd( + closes: list[float], + fast_period: int = 12, + slow_period: int = 26, + signal_period: int = 9, +) -> tuple[list[float | None], list[float | None], list[float | None]]: + ema_fast = _ema(closes, fast_period) + ema_slow = _ema(closes, slow_period) + macd_line: list[float | None] = [] + compact_macd: list[float] = [] + compact_indexes: list[int] = [] + for index, (fast, slow) in enumerate(zip(ema_fast, ema_slow)): + value = None if fast is None or slow is None else fast - slow + macd_line.append(value) + if value is not None: + compact_macd.append(value) + compact_indexes.append(index) + + signal_line: list[float | None] = [None] * len(closes) + hist: list[float | None] = [None] * len(closes) + compact_signal = _ema(compact_macd, signal_period) + for compact_index, original_index in enumerate(compact_indexes): + signal = compact_signal[compact_index] + macd_value = macd_line[original_index] + signal_line[original_index] = signal + if macd_value is not None and signal is not None: + hist[original_index] = macd_value - signal + return macd_line, signal_line, hist diff --git a/crypto_spot_bot/market_data.py b/crypto_spot_bot/market_data.py index 1d1355d..5071caa 100644 --- a/crypto_spot_bot/market_data.py +++ b/crypto_spot_bot/market_data.py @@ -15,7 +15,7 @@ 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"] +POPULAR_FALLBACK = ["BTCUSDT", "ETHUSDT", "SOLUSDT"] def _float(value: Any, default: float = 0.0) -> float: @@ -34,6 +34,7 @@ class MarketData: self.instruments: dict[str, Instrument] = {} self.tickers: dict[str, Ticker] = {} self.candles: dict[str, list[Candle]] = {} + self.trend_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]] = {} @@ -77,6 +78,13 @@ class MarketData: ) add_indicators(candles) self.candles[symbol] = candles + trend_candles = self.client.klines( + symbol=symbol, + interval=self.settings.trend_interval, + limit=self.settings.trend_kline_limit, + ) + add_indicators(trend_candles) + self.trend_candles[symbol] = trend_candles bid, ask = self.client.orderbook_top(symbol) self.orderbook_top[symbol] = (bid, ask) if symbol in self.tickers: @@ -101,7 +109,7 @@ class MarketData: 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)) + await ws.send(websocket_subscribe_message(self.symbols, self.settings.base_interval)) self.storage.event("Поток данных Bybit подключен") async for raw in ws: self.last_ws_message_at = utc_now() @@ -215,6 +223,7 @@ class MarketData: { "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:]], + "trend_candles": [candle.as_dict() for candle in self.trend_candles.get(symbol, [])[-5:]], "pattern": self.patterns.get(symbol), "forecast": self.forecasts.get(symbol), "instrument": asdict(self.instruments[symbol]) if symbol in self.instruments else None, diff --git a/crypto_spot_bot/models.py b/crypto_spot_bot/models.py index f245334..bfcc1a1 100644 --- a/crypto_spot_bot/models.py +++ b/crypto_spot_bot/models.py @@ -23,6 +23,9 @@ class Candle: ema_200: float | None = None rsi_14: float | None = None atr_14: float | None = None + macd: float | None = None + macd_signal: float | None = None + macd_hist: float | None = None volume_ma_20: float | None = None def as_dict(self) -> dict[str, Any]: diff --git a/crypto_spot_bot/strategy.py b/crypto_spot_bot/strategy.py index 6f13bb1..0d545c3 100644 --- a/crypto_spot_bot/strategy.py +++ b/crypto_spot_bot/strategy.py @@ -22,7 +22,18 @@ class SpotStrategy: llm: dict | None = None, forecast: dict | None = None, account: dict | None = None, + trend_candles: list[Candle] | None = None, ) -> Signal: + if self.settings.strategy_mode == "trend_macd": + return _trend_macd_entry_signal( + settings=self.settings, + symbol=symbol, + candles=candles, + trend_candles=trend_candles or [], + ticker=ticker, + open_positions_for_symbol=open_positions_for_symbol, + account=account, + ) if ticker is None: return Signal(symbol, "HOLD", 0.0, "нет ticker-данных") if len(candles) < 200: @@ -343,6 +354,8 @@ class SpotStrategy: learning: dict | None = None, forecast: dict | None = None, ) -> Signal: + if self.settings.strategy_mode == "trend_macd": + return _trend_macd_exit_signal(self.settings, position, candles, ticker) if ticker is None: return Signal(position.symbol, "HOLD", 0.0, "нет ticker-данных для выхода") if not candles: @@ -455,6 +468,192 @@ def _has_entry_indicators(candle: Candle) -> bool: ) +def _trend_macd_entry_signal( + *, + settings: Settings, + symbol: str, + candles: list[Candle], + trend_candles: list[Candle], + ticker: Ticker | None, + open_positions_for_symbol: int, + account: dict | None, +) -> Signal: + if ticker is None: + return Signal(symbol, "HOLD", 0.0, "нет ticker-данных") + if open_positions_for_symbol > 0: + return Signal(symbol, "HOLD", 0.0, "позиция по паре уже открыта") + if len(candles) < 60: + return Signal(symbol, "HOLD", 0.0, "недостаточно 1h свечей для trend_macd") + if len(trend_candles) < 200: + return Signal(symbol, "HOLD", 0.0, "недостаточно 1d свечей для EMA200") + + latest = candles[-1] + previous = candles[-2] + trend_latest = trend_candles[-1] + if not _has_trend_entry_indicators(latest, previous, trend_latest): + return Signal(symbol, "HOLD", 0.0, "индикаторы trend_macd еще не готовы") + + spread_ok = ticker.spread_percent <= settings.max_spread_percent + liquidity_ok = ticker.turnover_24h >= settings.min_24h_turnover_usdt + daily_trend_ok = bool(trend_latest.close > trend_latest.ema_200 and trend_latest.ema_50 > trend_latest.ema_200) + macd_cross_up = _macd_crossed_up(previous, latest) + price_above_ema50 = bool(latest.close > latest.ema_50) + rsi_min = min(settings.trend_rsi_min, settings.trend_rsi_max) + rsi_max = max(settings.trend_rsi_min, settings.trend_rsi_max) + rsi_ok = bool(rsi_min <= latest.rsi_14 <= rsi_max) + stop_loss_percent = _clamp(settings.stop_loss_percent, 0.003, 0.08) + sizing = _trend_position_sizing(settings, account, stop_loss_percent) + position_notional = float(sizing["notional_usdt"]) + checks = { + "spread_ok": spread_ok, + "liquidity_ok": liquidity_ok, + "daily_trend_ok": daily_trend_ok, + "macd_cross_up": macd_cross_up, + "price_above_ema50": price_above_ema50, + "rsi_ok": rsi_ok, + "risk_size_ok": position_notional >= settings.min_position_usdt, + } + diagnostics = { + "strategy_mode": "trend_macd", + "trade_mode": "TREND_MACD", + "position_notional_usdt": position_notional, + "position_sizing": sizing, + "stop_loss_percent": stop_loss_percent, + "atr_trailing_multiplier": _clamp(settings.atr_trailing_multiplier, 0.5, 10.0), + "entry_timeframe": settings.base_interval, + "trend_timeframe": settings.trend_interval, + "rsi_14": latest.rsi_14, + "rsi_min": rsi_min, + "rsi_max": rsi_max, + "ema_50": latest.ema_50, + "macd": latest.macd, + "macd_signal": latest.macd_signal, + "trend_close": trend_latest.close, + "trend_ema_50": trend_latest.ema_50, + "trend_ema_200": trend_latest.ema_200, + "spread_percent": round(ticker.spread_percent, 5), + "turnover_24h": ticker.turnover_24h, + "checks": checks, + "grid": {"enabled": False, "active": False}, + "rebound": {"enabled": False, "active": False}, + "forecast": {}, + "learning": {}, + "llm": {}, + } + if all(checks.values()): + return Signal( + symbol, + "BUY", + 0.86, + f"trend_macd: 1d тренд вверх, MACD пересек signal вверх, RSI {latest.rsi_14:.1f}, размер {position_notional:.2f} USDT", + diagnostics, + ) + failed = ", ".join(name for name, ok in checks.items() if not ok) + return Signal(symbol, "HOLD", 0.35, f"trend_macd: условия входа не выполнены ({failed})", diagnostics) + + +def _trend_macd_exit_signal( + settings: Settings, + position: Position, + candles: list[Candle], + ticker: Ticker | None, +) -> Signal: + if ticker is None: + return Signal(position.symbol, "HOLD", 0.0, "нет ticker-данных для выхода") + if len(candles) < 2: + return Signal(position.symbol, "HOLD", 0.0, "недостаточно 1h свечей для выхода") + latest = candles[-1] + previous = candles[-2] + price = ticker.last_price + stop_loss_percent = _clamp(settings.stop_loss_percent, 0.003, 0.08) + effective_stop_loss = max(position.stop_loss, position.entry_price * (1 - stop_loss_percent)) + atr_multiplier = _clamp(settings.atr_trailing_multiplier, 0.5, 10.0) + atr_trailing_stop = None + if latest.atr_14 is not None and position.highest_price > position.entry_price: + atr_trailing_stop = max(effective_stop_loss, position.highest_price - latest.atr_14 * atr_multiplier) + macd_cross_down = _macd_crossed_down(previous, latest) + close_below_ema50 = latest.ema_50 is not None and latest.close < latest.ema_50 + diagnostics = { + "strategy_mode": "trend_macd", + "price": price, + "entry_price": position.entry_price, + "stop_loss": effective_stop_loss, + "atr_trailing_stop": atr_trailing_stop, + "atr_trailing_multiplier": atr_multiplier, + "highest_price": position.highest_price, + "ema_50": latest.ema_50, + "rsi_14": latest.rsi_14, + "atr_14": latest.atr_14, + "macd": latest.macd, + "macd_signal": latest.macd_signal, + "macd_cross_down": macd_cross_down, + "close_below_ema50": close_below_ema50, + } + if price <= effective_stop_loss: + return Signal(position.symbol, "SELL", 1.0, "trend_macd: сработал стоп-лосс", diagnostics) + if atr_trailing_stop is not None and price <= atr_trailing_stop: + return Signal(position.symbol, "SELL", 0.94, "trend_macd: сработал ATR trailing stop", diagnostics) + if macd_cross_down: + return Signal(position.symbol, "SELL", 0.84, "trend_macd: MACD пересек signal вниз", diagnostics) + if close_below_ema50: + return Signal(position.symbol, "SELL", 0.82, "trend_macd: 1h свеча закрылась ниже EMA50", diagnostics) + return Signal(position.symbol, "HOLD", 0.35, "trend_macd: условия выхода не выполнены", diagnostics) + + +def _has_trend_entry_indicators(current: Candle, previous: Candle, trend: Candle) -> bool: + return all( + value is not None + for value in ( + current.ema_50, + current.rsi_14, + current.atr_14, + current.macd, + current.macd_signal, + previous.macd, + previous.macd_signal, + trend.ema_50, + trend.ema_200, + ) + ) + + +def _macd_crossed_up(previous: Candle, current: Candle) -> bool: + if None in (previous.macd, previous.macd_signal, current.macd, current.macd_signal): + return False + return bool(previous.macd <= previous.macd_signal and current.macd > current.macd_signal) + + +def _macd_crossed_down(previous: Candle, current: Candle) -> bool: + if None in (previous.macd, previous.macd_signal, current.macd, current.macd_signal): + return False + return bool(previous.macd >= previous.macd_signal and current.macd < current.macd_signal) + + +def _trend_position_sizing( + settings: Settings, + account: dict | None, + stop_loss_percent: float, +) -> dict[str, float | str]: + equity = _safe_float((account or {}).get("equity"), settings.starting_balance_usdt) + if equity <= 0: + equity = settings.starting_balance_usdt + risk_fraction = _clamp(settings.risk_per_trade_percent, 0.0, 0.01) + risk_usdt = equity * risk_fraction + raw_notional = risk_usdt / max(stop_loss_percent, 0.0001) + high = max(0.0, settings.max_position_usdt) + low = max(0.0, settings.min_position_usdt) + notional = 0.0 if raw_notional < low else min(raw_notional, high) + return { + "method": "fixed_fractional_risk", + "risk_per_trade_percent": round(risk_fraction * 100, 4), + "risk_usdt": round(risk_usdt, 4), + "stop_loss_percent": round(stop_loss_percent * 100, 4), + "raw_notional_usdt": round(raw_notional, 4), + "notional_usdt": round(notional, 2), + "equity_usdt": round(equity, 2), + } + + def _decision_suffix(pattern: dict, learning: dict, llm: dict | None = None) -> str: parts: list[str] = [] label = pattern.get("label") diff --git a/tests/conftest.py b/tests/conftest.py index 85c6221..4100294 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -21,8 +21,11 @@ def make_settings(): auto_select_symbols=True, top_symbols_count=6, symbols=(), - base_interval="1", + strategy_mode="legacy", + base_interval="60", kline_limit=240, + trend_interval="D", + trend_kline_limit=260, loop_interval_seconds=5, fast_trading_enabled=False, fast_loop_interval_seconds=1.0, @@ -62,6 +65,10 @@ def make_settings(): kelly_sizing_enabled=True, kelly_fraction=0.25, kelly_max_fraction=0.20, + risk_per_trade_percent=0.01, + atr_trailing_multiplier=2.2, + trend_rsi_min=45.0, + trend_rsi_max=65.0, time_series_forecast_enabled=True, time_series_min_candles=120, time_series_forecast_horizon=3, diff --git a/tests/test_bybit.py b/tests/test_bybit.py index 00fbe19..7535c99 100644 --- a/tests/test_bybit.py +++ b/tests/test_bybit.py @@ -1,6 +1,6 @@ from __future__ import annotations -from crypto_spot_bot.bybit import BybitClient, _looks_like_leveraged_token, _looks_like_stablecoin +from crypto_spot_bot.bybit import BybitClient, websocket_subscribe_message, _looks_like_leveraged_token, _looks_like_stablecoin def test_leveraged_token_filter() -> None: @@ -56,3 +56,10 @@ def test_live_spot_order_explicitly_disables_leverage(make_settings, tmp_path) - assert captured["payload"]["category"] == "spot" assert captured["payload"]["isLeverage"] == 0 assert captured["payload"]["orderFilter"] == "Order" + + +def test_websocket_subscribe_uses_configured_kline_interval() -> None: + payload = websocket_subscribe_message(["BTCUSDT"], interval="60") + + assert "kline.60.BTCUSDT" in payload + assert "kline.1.BTCUSDT" not in payload diff --git a/tests/test_config.py b/tests/test_config.py index e0d7948..4dfed02 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -63,10 +63,11 @@ def test_llm_advisor_is_disabled_by_default(tmp_path, monkeypatch) -> None: assert settings.llm_advisor_enabled is False -def test_default_symbols_are_fixed_six_pairs(tmp_path, monkeypatch) -> None: +def test_default_symbols_are_fixed_trend_pairs(tmp_path, monkeypatch) -> None: monkeypatch.delenv("AUTO_SELECT_SYMBOLS", raising=False) monkeypatch.delenv("TOP_SYMBOLS_COUNT", raising=False) monkeypatch.delenv("SYMBOLS", raising=False) + monkeypatch.delenv("STRATEGY_MODE", raising=False) monkeypatch.setenv("TRADING_MODE", "paper") env_file = tmp_path / ".env" env_file.write_text("TRADING_MODE=paper\nSYMBOLS=\n", encoding="utf-8") @@ -74,5 +75,9 @@ def test_default_symbols_are_fixed_six_pairs(tmp_path, monkeypatch) -> None: settings = load_settings(env_file) assert settings.auto_select_symbols is False - assert settings.top_symbols_count == 6 + assert settings.top_symbols_count == 3 assert settings.symbols == FIXED_SPOT_SYMBOLS + assert settings.strategy_mode == "trend_macd" + assert settings.base_interval == "60" + assert settings.trend_interval == "D" + assert settings.risk_per_trade_percent == 0.01 diff --git a/tests/test_execution.py b/tests/test_execution.py index eab6753..c52c00c 100644 --- a/tests/test_execution.py +++ b/tests/test_execution.py @@ -129,3 +129,28 @@ def test_paper_broker_respects_adaptive_exposure_target(make_settings, tmp_path) assert position is None assert broker.open_positions() == [] + + +def test_trend_macd_broker_blocks_dca_for_same_symbol(make_settings, tmp_path) -> None: + settings = make_settings( + tmp_path, + strategy_mode="trend_macd", + min_position_usdt=1, + max_position_usdt=20, + max_symbol_exposure_usdt=20, + max_total_exposure_usdt=80, + max_open_positions=3, + 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) + + 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}) + + assert first is not None + assert second is None + assert len(broker.open_positions()) == 1 diff --git a/tests/test_indicators.py b/tests/test_indicators.py index 8f1e784..7d58996 100644 --- a/tests/test_indicators.py +++ b/tests/test_indicators.py @@ -25,4 +25,7 @@ def test_add_indicators_populates_long_periods() -> None: assert latest.ema_200 is not None assert latest.rsi_14 is not None assert latest.atr_14 is not None + assert latest.macd is not None + assert latest.macd_signal is not None + assert latest.macd_hist is not None assert latest.volume_ma_20 is not None diff --git a/tests/test_strategy.py b/tests/test_strategy.py index 8564428..eb4c84e 100644 --- a/tests/test_strategy.py +++ b/tests/test_strategy.py @@ -59,6 +59,180 @@ def _rebound_candles() -> list[Candle]: return candles +def _trend_entry_candles( + *, + macd_cross_up: bool = True, + close: float = 105.0, + ema50: float = 100.0, + rsi: float = 55.0, +) -> list[Candle]: + candles = [] + for index in range(80): + candles.append( + Candle( + timestamp=index, + open=close - 0.4, + high=close + 0.8, + low=close - 0.8, + close=close, + volume=100, + ema_20=close - 1.0, + ema_50=ema50, + ema_200=ema50 - 4.0, + rsi_14=rsi, + atr_14=1.0, + macd=-0.1, + macd_signal=0.0, + macd_hist=-0.1, + volume_ma_20=90, + ) + ) + candles[-1].macd = 0.15 if macd_cross_up else -0.05 + candles[-1].macd_signal = 0.0 + candles[-1].macd_hist = candles[-1].macd - candles[-1].macd_signal + return candles + + +def _daily_trend_candles(*, uptrend: bool = True) -> list[Candle]: + close = 110.0 if uptrend else 95.0 + ema50 = 105.0 if uptrend else 98.0 + ema200 = 100.0 + return [ + Candle( + timestamp=index, + open=close - 1.0, + high=close + 1.0, + low=close - 2.0, + close=close, + volume=1000, + ema_20=ema50, + ema_50=ema50, + ema_200=ema200, + rsi_14=55, + atr_14=4.0, + macd=1.0, + macd_signal=0.8, + macd_hist=0.2, + volume_ma_20=900, + ) + for index in range(205) + ] + + +def test_trend_macd_buys_only_when_rules_pass(make_settings, tmp_path) -> None: + settings = make_settings( + tmp_path, + strategy_mode="trend_macd", + max_position_usdt=50, + stop_loss_percent=0.04, + risk_per_trade_percent=0.01, + ) + strategy = SpotStrategy(settings) + ticker = Ticker("BTCUSDT", 105, 104.99, 105.01, 10_000_000, 1000, 1.0) + + signal = strategy.entry_signal( + "BTCUSDT", + _trend_entry_candles(), + ticker, + open_positions_for_symbol=0, + account={"equity": 100.0}, + trend_candles=_daily_trend_candles(), + ) + + assert signal.action == "BUY" + assert signal.diagnostics["strategy_mode"] == "trend_macd" + assert signal.diagnostics["position_notional_usdt"] == 25.0 + assert signal.diagnostics["position_sizing"]["risk_per_trade_percent"] == 1.0 + + +def test_trend_macd_blocks_when_daily_trend_filter_fails(make_settings, tmp_path) -> None: + settings = make_settings(tmp_path, strategy_mode="trend_macd") + strategy = SpotStrategy(settings) + ticker = Ticker("ETHUSDT", 105, 104.99, 105.01, 10_000_000, 1000, 1.0) + + signal = strategy.entry_signal( + "ETHUSDT", + _trend_entry_candles(), + ticker, + open_positions_for_symbol=0, + account={"equity": 100.0}, + trend_candles=_daily_trend_candles(uptrend=False), + ) + + assert signal.action == "HOLD" + assert signal.diagnostics["checks"]["daily_trend_ok"] is False + + +def test_trend_macd_blocks_second_position_for_symbol(make_settings, tmp_path) -> None: + settings = make_settings(tmp_path, strategy_mode="trend_macd") + strategy = SpotStrategy(settings) + ticker = Ticker("SOLUSDT", 105, 104.99, 105.01, 10_000_000, 1000, 1.0) + + signal = strategy.entry_signal( + "SOLUSDT", + _trend_entry_candles(), + ticker, + open_positions_for_symbol=1, + account={"equity": 100.0}, + trend_candles=_daily_trend_candles(), + ) + + assert signal.action == "HOLD" + assert "позиция" in signal.reason + + +def test_trend_macd_exits_on_macd_cross_down(make_settings, tmp_path) -> None: + settings = make_settings(tmp_path, strategy_mode="trend_macd") + strategy = SpotStrategy(settings) + candles = _trend_entry_candles() + candles[-2].macd = 0.2 + candles[-2].macd_signal = 0.0 + candles[-1].macd = -0.1 + candles[-1].macd_signal = 0.0 + position = Position(1, "BTCUSDT", 1, 100, 100, 0.1, 96, 120, 100) + ticker = Ticker("BTCUSDT", 104, 103.99, 104.01, 1_000_000, 100, 0) + + signal = strategy.exit_signal(position, candles, ticker) + + assert signal.action == "SELL" + assert "MACD" in signal.reason + + +def test_trend_macd_exits_on_close_below_ema50(make_settings, tmp_path) -> None: + settings = make_settings(tmp_path, strategy_mode="trend_macd") + strategy = SpotStrategy(settings) + candles = _trend_entry_candles(close=99.0, ema50=100.0, macd_cross_up=False) + candles[-2].macd = 0.1 + candles[-2].macd_signal = 0.0 + candles[-1].macd = 0.05 + candles[-1].macd_signal = 0.0 + position = Position(1, "BTCUSDT", 1, 100, 100, 0.1, 96, 120, 100) + ticker = Ticker("BTCUSDT", 99, 98.99, 99.01, 1_000_000, 100, 0) + + signal = strategy.exit_signal(position, candles, ticker) + + assert signal.action == "SELL" + assert "EMA50" in signal.reason + + +def test_trend_macd_exits_on_atr_trailing_stop(make_settings, tmp_path) -> None: + settings = make_settings(tmp_path, strategy_mode="trend_macd", atr_trailing_multiplier=2.2) + strategy = SpotStrategy(settings) + candles = _trend_entry_candles(close=108.0, ema50=100.0, macd_cross_up=False) + candles[-2].macd = 0.1 + candles[-2].macd_signal = 0.0 + candles[-1].macd = 0.05 + candles[-1].macd_signal = 0.0 + candles[-1].atr_14 = 1.0 + position = Position(1, "BTCUSDT", 1, 100, 100, 0.1, 96, 120, 110) + ticker = Ticker("BTCUSDT", 107.7, 107.69, 107.71, 1_000_000, 100, 0) + + signal = strategy.exit_signal(position, candles, ticker) + + assert signal.action == "SELL" + assert "ATR trailing" in signal.reason + + def test_strategy_emits_buy_when_score_passes_threshold(make_settings, tmp_path) -> None: settings = make_settings(tmp_path) strategy = SpotStrategy(settings) diff --git a/tools/install_windows_torch_retrainer.ps1 b/tools/install_windows_torch_retrainer.ps1 index 5a0ce37..ed3b4c0 100644 --- a/tools/install_windows_torch_retrainer.ps1 +++ b/tools/install_windows_torch_retrainer.ps1 @@ -2,7 +2,7 @@ param( [string]$TaskName = "TradeBot PyTorch Forecaster Retrainer", [int]$EveryHours = 6, - [string]$Symbols = "BTCUSDT,ETHUSDT,HYPEUSDT,SOLUSDT,LTCUSDT,XRPUSDT", + [string]$Symbols = "BTCUSDT,ETHUSDT,SOLUSDT", [int]$Limit = 1000, [int]$FirstRunMinutes = 0 )