From d0869b5d29f6a440c2edd903a333990e7215775a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D1=83=D1=80=D0=BD=D0=B0=D1=82=20=D0=90=D0=BD=D0=B4?= =?UTF-8?q?=D1=80=D0=B5=D0=B9?= Date: Wed, 15 Jul 2026 00:23:03 +0300 Subject: [PATCH] fix: keep paper trading active without approved model --- .env.example | 1 + README.md | 4 +- crypto_spot_bot/__init__.py | 2 +- crypto_spot_bot/bot.py | 5 ++ crypto_spot_bot/config.py | 6 +++ crypto_spot_bot/dashboard.py | 1 + crypto_spot_bot/strategy.py | 85 +++++++++++++++++++++++++++++----- tests/test_config.py | 9 ++++ tests/test_dashboard.py | 1 + tests/test_strategy.py | 88 ++++++++++++++++++++++++++++++++++++ 10 files changed, 188 insertions(+), 14 deletions(-) diff --git a/.env.example b/.env.example index 30aa723..2cd12b9 100644 --- a/.env.example +++ b/.env.example @@ -83,6 +83,7 @@ TIME_SERIES_REBOUND_FALLBACK_ENABLED=false # Use the independently guarded trend/MACD strategy while no accepted fresh # Torch model is available. The rejected model is never used for entries. TIME_SERIES_TREND_FALLBACK_ENABLED=true +TIME_SERIES_FALLBACK_MODE=legacy TIME_SERIES_REQUIRE_QUALITY_GATE=true # Emergency paper-only override. Keep false unless a failed guard is accepted manually. TIME_SERIES_MANUAL_QUALITY_OVERRIDE=false diff --git a/README.md b/README.md index b8f0961..74bcef7 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Spot-бот для демо-торговли криптовалютой на р - Spot-only логика: покупка базовой монеты за USDT и продажа обратно, без short и без плеча. - Live spot-ордеры явно отправляются без плеча: `category=spot`, `isLeverage=0`. - Основная стратегия `torch_forecast`: входы и forecast-выходы идут только от свежей экспортированной PyTorch LSTM/GRU модели с успешным quality gate; MACD/RSI/дневная EMA не являются условиями входа в этом режиме. Rebound fallback без модели выключен по умолчанию. Спред, ликвидность, stop-loss, ATR trailing stop, запрет DCA и лимиты экспозиции остаются защитой исполнения и риска. -- При `TIME_SERIES_TREND_FALLBACK_ENABLED=true` отсутствие принятой свежей Torch-модели включает самостоятельную `trend_macd`-стратегию. Отклонённый artifact не используется, fallback явно отражается в readiness и диагностике сигналов, а после появления принятой модели выключается автоматически. +- При `TIME_SERIES_TREND_FALLBACK_ENABLED=true` отсутствие принятой свежей Torch-модели включает самостоятельную fallback-стратегию. `TIME_SERIES_FALLBACK_MODE=legacy` разрешён только для paper и даёт многорежимные виртуальные входы; live всегда принудительно использует более строгий `trend_macd`. Отклонённый artifact не используется, fallback явно отражается в readiness и диагностике сигналов, а после появления принятой модели выключается автоматически. - Основная стратегия `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`. @@ -193,6 +193,8 @@ TIME_SERIES_PROBE_MIN_EDGE_PERCENT=0.02 TIME_SERIES_PROBE_MIN_PROBABILITY_UP=0.55 TIME_SERIES_PROBE_SIZE_MULTIPLIER=0.40 TIME_SERIES_REBOUND_FALLBACK_ENABLED=false +TIME_SERIES_TREND_FALLBACK_ENABLED=true +TIME_SERIES_FALLBACK_MODE=legacy TIME_SERIES_REQUIRE_QUALITY_GATE=true TIME_SERIES_REQUIRE_FRESH_MODEL=true TIME_SERIES_MODEL_MAX_AGE_HOURS=48 diff --git a/crypto_spot_bot/__init__.py b/crypto_spot_bot/__init__.py index 400bd7d..9bd75b8 100644 --- a/crypto_spot_bot/__init__.py +++ b/crypto_spot_bot/__init__.py @@ -1,3 +1,3 @@ """Crypto spot trading bot package.""" -__version__ = "1.0.0" +__version__ = "1.0.1" diff --git a/crypto_spot_bot/bot.py b/crypto_spot_bot/bot.py index ceb8952..a5d40cb 100644 --- a/crypto_spot_bot/bot.py +++ b/crypto_spot_bot/bot.py @@ -397,6 +397,11 @@ class CryptoSpotBot: self.settings.pattern_analysis_enabled or self.settings.grid_trading_enabled or self.settings.rebound_trading_enabled + or ( + self.settings.strategy_mode == "torch_forecast" + and self.settings.time_series_trend_fallback_enabled + and self.settings.time_series_fallback_mode == "legacy" + ) ) if self.settings.strategy_mode == "trend_macd" or not patterns_needed: self.market.patterns = {} diff --git a/crypto_spot_bot/config.py b/crypto_spot_bot/config.py index d43b3bb..da8c0ec 100644 --- a/crypto_spot_bot/config.py +++ b/crypto_spot_bot/config.py @@ -171,6 +171,7 @@ class Settings: storage_prune_interval_seconds: int = 3600 bybit_rest_base_url_override: str = "" bybit_websocket_url_override: str = "" + time_series_fallback_mode: str = "trend_macd" @property def rest_base_url(self) -> str: @@ -354,6 +355,9 @@ def load_settings(env_file: str | Path | None = None) -> Settings: "" if _bool_env("BYBIT_TESTNET", False) else "https://api.bybit.kz", ).strip(), bybit_websocket_url_override=os.getenv("BYBIT_WEBSOCKET_URL", "").strip(), + time_series_fallback_mode=os.getenv( + "TIME_SERIES_FALLBACK_MODE", "trend_macd" + ).strip().lower(), ) _validate_settings(settings) if settings.trading_mode == "live" and not settings.live_ready: @@ -390,6 +394,8 @@ def _validate_settings(settings: Settings) -> None: errors.append("LIVE_ORDER_FILL_TIMEOUT_SECONDS must be positive") if settings.live_reconciliation_interval_seconds <= 0: errors.append("LIVE_RECONCILIATION_INTERVAL_SECONDS must be positive") + if settings.time_series_fallback_mode not in {"trend_macd", "legacy"}: + errors.append("TIME_SERIES_FALLBACK_MODE must be trend_macd or legacy") if errors: raise ValueError("; ".join(errors)) diff --git a/crypto_spot_bot/dashboard.py b/crypto_spot_bot/dashboard.py index a34e844..4d8003b 100644 --- a/crypto_spot_bot/dashboard.py +++ b/crypto_spot_bot/dashboard.py @@ -401,6 +401,7 @@ def _safe_config(settings: Settings) -> dict[str, Any]: "time_series_probe_size_multiplier": settings.time_series_probe_size_multiplier, "time_series_rebound_fallback_enabled": settings.time_series_rebound_fallback_enabled, "time_series_trend_fallback_enabled": settings.time_series_trend_fallback_enabled, + "time_series_fallback_mode": settings.time_series_fallback_mode, "time_series_require_quality_gate": settings.time_series_require_quality_gate, "time_series_manual_quality_override": settings.time_series_manual_quality_override, "time_series_require_fresh_model": settings.time_series_require_fresh_model, diff --git a/crypto_spot_bot/strategy.py b/crypto_spot_bot/strategy.py index cd10958..1419ed7 100644 --- a/crypto_spot_bot/strategy.py +++ b/crypto_spot_bot/strategy.py @@ -1,5 +1,7 @@ from __future__ import annotations +from dataclasses import replace + from crypto_spot_bot.config import Settings from crypto_spot_bot.models import Candle, Position, Signal, Ticker, utc_now @@ -27,21 +29,45 @@ class SpotStrategy: if self.settings.strategy_mode == "torch_forecast": fallback_reasons = torch_model_readiness_reasons(self.settings, forecast or {}) if self.settings.time_series_trend_fallback_enabled and fallback_reasons: - fallback = _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, - ) + fallback_mode = _effective_fallback_mode(self.settings) + if fallback_mode == "legacy": + fallback_settings = replace( + self.settings, + strategy_mode="legacy", + time_series_forecast_enabled=False, + ) + fallback = SpotStrategy(fallback_settings).entry_signal( + symbol, + candles, + ticker, + open_positions_for_symbol, + pattern, + learning, + llm, + {}, + account, + trend_candles, + ) + trade_mode = "LEGACY_FALLBACK" + entry_path = "legacy_fallback" + else: + fallback = _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, + ) + trade_mode = "TREND_MACD_FALLBACK" + entry_path = "trend_macd_fallback" diagnostics = dict(fallback.diagnostics) diagnostics.update( { "strategy_mode": "torch_forecast", - "trade_mode": "TREND_MACD_FALLBACK", - "entry_path": "trend_macd_fallback", + "trade_mode": trade_mode, + "entry_path": entry_path, "forecast_fallback_active": True, "forecast_fallback_reasons": fallback_reasons, "forecast": forecast or {}, @@ -397,7 +423,36 @@ class SpotStrategy: forecast: dict | None = None, ) -> Signal: if self.settings.strategy_mode == "torch_forecast": - if str(position.entry_diagnostics.get("entry_path", "")) == "trend_macd_fallback": + entry_path = str(position.entry_diagnostics.get("entry_path", "")) + if entry_path == "legacy_fallback": + fallback_settings = replace( + self.settings, + strategy_mode="legacy", + time_series_forecast_enabled=False, + ) + fallback = SpotStrategy(fallback_settings)._legacy_exit_signal( + position, + candles, + ticker, + learning, + ) + diagnostics = dict(fallback.diagnostics) + diagnostics.update( + { + "strategy_mode": "torch_forecast", + "trade_mode": "LEGACY_FALLBACK", + "entry_path": "legacy_fallback", + "forecast_fallback_active": True, + } + ) + return Signal( + fallback.symbol, + fallback.action, + fallback.confidence, + f"torch_forecast fallback: {fallback.reason}", + diagnostics, + ) + if entry_path == "trend_macd_fallback": fallback = _trend_macd_exit_signal(self.settings, position, candles, ticker) diagnostics = dict(fallback.diagnostics) diagnostics.update( @@ -534,6 +589,12 @@ def _has_entry_indicators(candle: Candle) -> bool: ) +def _effective_fallback_mode(settings: Settings) -> str: + if settings.trading_mode != "paper": + return "trend_macd" + return settings.time_series_fallback_mode + + def _trend_macd_entry_signal( *, settings: Settings, diff --git a/tests/test_config.py b/tests/test_config.py index 810f050..f1146de 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -171,3 +171,12 @@ def test_load_settings_rejects_inconsistent_exposure_limits(tmp_path, monkeypatc with pytest.raises(ValueError, match="MAX_SYMBOL_EXPOSURE_USDT"): load_settings(env_file) + + +def test_load_settings_rejects_unknown_fallback_mode(tmp_path, monkeypatch) -> None: + monkeypatch.delenv("TIME_SERIES_FALLBACK_MODE", raising=False) + env_file = tmp_path / ".env" + env_file.write_text("TIME_SERIES_FALLBACK_MODE=force-trades\n", encoding="utf-8") + + with pytest.raises(ValueError, match="TIME_SERIES_FALLBACK_MODE"): + load_settings(env_file) diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 2724dd8..756f1de 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -45,6 +45,7 @@ def test_safe_config_summarizes_torch_forecast_artifact(make_settings, tmp_path) assert config["time_series_probe_min_probability_up"] == 0.55 assert config["time_series_probe_size_multiplier"] == 0.40 assert config["time_series_rebound_fallback_enabled"] is True + assert config["time_series_fallback_mode"] == "trend_macd" assert config["time_series_model_artifact"] == { "available": True, "type": "pytorch_recurrent_forecaster", diff --git a/tests/test_strategy.py b/tests/test_strategy.py index 2ce3a2b..76d1e68 100644 --- a/tests/test_strategy.py +++ b/tests/test_strategy.py @@ -631,6 +631,94 @@ def test_torch_forecast_uses_trend_exit_for_fallback_position(make_settings, tmp assert "MACD" in signal.reason +def test_torch_forecast_uses_legacy_fallback_in_paper_mode(make_settings, tmp_path) -> None: + settings = make_settings( + tmp_path, + strategy_mode="torch_forecast", + time_series_trend_fallback_enabled=True, + time_series_fallback_mode="legacy", + time_series_require_quality_gate=True, + time_series_require_fresh_model=True, + grid_trading_enabled=False, + rebound_trading_enabled=False, + kelly_sizing_enabled=False, + ) + 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=0, + forecast={"usable": False, "model": "none", "quality_gate_passed": False}, + account={"equity": 100.0, "cash": 100.0, "exposure": 0.0}, + ) + + assert signal.action == "BUY" + assert signal.diagnostics["trade_mode"] == "LEGACY_FALLBACK" + assert signal.diagnostics["entry_path"] == "legacy_fallback" + assert signal.diagnostics["forecast_fallback_active"] is True + + +def test_torch_forecast_forces_trend_fallback_in_live_mode(make_settings, tmp_path) -> None: + settings = make_settings( + tmp_path, + trading_mode="live", + strategy_mode="torch_forecast", + time_series_trend_fallback_enabled=True, + time_series_fallback_mode="legacy", + time_series_require_quality_gate=True, + time_series_require_fresh_model=True, + max_position_usdt=50, + ) + 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, + forecast={"usable": False, "model": "none", "quality_gate_passed": False}, + account={"equity": 100.0}, + trend_candles=_daily_trend_candles(), + ) + + assert signal.action == "BUY" + assert signal.diagnostics["trade_mode"] == "TREND_MACD_FALLBACK" + assert signal.diagnostics["entry_path"] == "trend_macd_fallback" + + +def test_torch_forecast_uses_legacy_exit_for_paper_fallback_position(make_settings, tmp_path) -> None: + settings = make_settings( + tmp_path, + strategy_mode="torch_forecast", + time_series_trend_fallback_enabled=True, + time_series_fallback_mode="legacy", + ) + strategy = SpotStrategy(settings) + position = Position( + 1, + "BTCUSDT", + 1, + 100, + 100, + 0.1, + 96, + 103.5, + 100, + entry_diagnostics={"entry_path": "legacy_fallback"}, + ) + ticker = Ticker("BTCUSDT", 104, 103.99, 104.01, 10_000_000, 1000, 1.0) + + signal = strategy.exit_signal(position, _ready_candles(), ticker, forecast={}) + + assert signal.action == "SELL" + assert signal.diagnostics["trade_mode"] == "LEGACY_FALLBACK" + assert signal.diagnostics["entry_path"] == "legacy_fallback" + + def test_torch_forecast_allows_explicit_manual_quality_override(make_settings, tmp_path) -> None: settings = make_settings( tmp_path,