fix: keep paper trading active without approved model
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""Crypto spot trading bot package."""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
__version__ = "1.0.1"
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,6 +29,28 @@ 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_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,
|
||||
@@ -36,12 +60,14 @@ class SpotStrategy:
|
||||
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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user