From 544b0f4409c2fe1e40e4a9e1193b7644d27e1b4f Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 22 Jun 2026 05:21:05 +0300 Subject: [PATCH] Use Torch forecast as primary strategy --- .env.example | 4 +- README.md | 7 +- crypto_spot_bot/bot.py | 11 +- crypto_spot_bot/config.py | 22 ++-- crypto_spot_bot/execution.py | 2 +- crypto_spot_bot/strategy.py | 213 +++++++++++++++++++++++++++++++++++ tests/test_config.py | 35 +++++- tests/test_execution.py | 25 ++++ tests/test_strategy.py | 75 ++++++++++++ 9 files changed, 374 insertions(+), 20 deletions(-) diff --git a/.env.example b/.env.example index f1ea280..6645674 100644 --- a/.env.example +++ b/.env.example @@ -11,7 +11,7 @@ AUTO_SELECT_SYMBOLS=false TOP_SYMBOLS_COUNT=3 SYMBOLS=BTCUSDT,ETHUSDT,SOLUSDT -STRATEGY_MODE=trend_macd +STRATEGY_MODE=torch_forecast BASE_INTERVAL=60 KLINE_LIMIT=240 TREND_INTERVAL=D @@ -53,7 +53,7 @@ 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_FORECAST_ENABLED=true TIME_SERIES_MIN_CANDLES=120 TIME_SERIES_FORECAST_HORIZON=3 TIME_SERIES_MIN_EDGE_PERCENT=0.04 diff --git a/README.md b/README.md index 809b980..0425baa 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ Spot-бот для демо-торговли криптовалютой на р - Paper trading с учетом cash, комиссий, проскальзывания, stop-loss, take-profit и trailing stop. - Spot-only логика: покупка базовой монеты за USDT и продажа обратно, без short и без плеча. - Live spot-ордеры явно отправляются без плеча: `category=spot`, `isLeverage=0`. +- Основная стратегия `torch_forecast`: входы и forecast-выходы идут только от экспортированной PyTorch LSTM/GRU модели; MACD/RSI/дневная EMA не являются условиями входа в этом режиме. Спред, ликвидность, stop-loss, ATR trailing stop, запрет DCA и лимиты экспозиции остаются защитой исполнения и риска. - Основная стратегия `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`. @@ -72,7 +73,7 @@ Dashboard: --epochs 60 ``` -Файл из `TIME_SERIES_LSTM_MODEL_PATH` читается ботом автоматически, если `TIME_SERIES_FORECAST_ENABLED=true`. В основной стратегии `trend_macd` прогноз выключен по умолчанию и не влияет на входы/выходы. Экспортированные модели появляются в dashboard как `PyTorch LSTM` или `PyTorch GRU`; старый легкий reservoir LSTM-кандидат и все встроенные не-torch прогнозы удалены. +Файл из `TIME_SERIES_LSTM_MODEL_PATH` читается ботом автоматически, если `TIME_SERIES_FORECAST_ENABLED=true`. В стратегии `torch_forecast` экспортированная PyTorch LSTM/GRU модель является единственным направляющим сигналом для входа и forecast-выхода. Экспортированные модели появляются в dashboard как `PyTorch LSTM` или `PyTorch GRU`; старый легкий reservoir LSTM-кандидат и все встроенные не-torch прогнозы удалены. Автопереобучение на Windows запускает PyTorch trainer, пишет лог в `runtime/torch_retrain.log` и защищается от параллельных запусков: @@ -103,7 +104,7 @@ STARTING_BALANCE_USDT=100 AUTO_SELECT_SYMBOLS=false TOP_SYMBOLS_COUNT=3 SYMBOLS=BTCUSDT,ETHUSDT,SOLUSDT -STRATEGY_MODE=trend_macd +STRATEGY_MODE=torch_forecast BASE_INTERVAL=60 TREND_INTERVAL=D TREND_KLINE_LIMIT=260 @@ -142,7 +143,7 @@ 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_FORECAST_ENABLED=true TIME_SERIES_MIN_CANDLES=120 TIME_SERIES_FORECAST_HORIZON=3 TIME_SERIES_MIN_EDGE_PERCENT=0.04 diff --git a/crypto_spot_bot/bot.py b/crypto_spot_bot/bot.py index a056225..881e409 100644 --- a/crypto_spot_bot/bot.py +++ b/crypto_spot_bot/bot.py @@ -200,7 +200,7 @@ class CryptoSpotBot: return enriched def _close_paper_positions_outside_symbol_universe(self) -> None: - if self.settings.strategy_mode != "trend_macd" or self.settings.trading_mode != "paper": + if self.settings.strategy_mode not in {"trend_macd", "torch_forecast"} or self.settings.trading_mode != "paper": return allowed_symbols = set(self.market.symbols or self.settings.symbols) for position in list(self.broker.open_positions()): @@ -218,10 +218,10 @@ class CryptoSpotBot: self.broker.sell( position, synthetic_ticker, - "trend_macd: закрыта старая paper-позиция вне списка разрешенных пар", + f"{self.settings.strategy_mode}: закрыта старая paper-позиция вне списка разрешенных пар", ) self.storage.event( - f"{position.symbol}: старая paper-позиция закрыта при переходе на trend_macd" + f"{position.symbol}: старая paper-позиция закрыта при переходе на {self.settings.strategy_mode}" ) def _reduction_candidate_id(self, prices: dict[str, float]) -> int | None: @@ -242,7 +242,7 @@ class CryptoSpotBot: return worst.id def _update_patterns(self) -> None: - if self.settings.strategy_mode == "trend_macd" or not self.settings.pattern_analysis_enabled: + if self.settings.strategy_mode in {"trend_macd", "torch_forecast"} or not self.settings.pattern_analysis_enabled: self.market.patterns = {} return patterns: dict[str, dict] = {} @@ -256,8 +256,7 @@ class CryptoSpotBot: def _update_forecasts(self) -> None: if ( - self.settings.strategy_mode == "trend_macd" - or self.forecaster is None + self.forecaster is None or not self.settings.time_series_forecast_enabled ): self.market.forecasts = {} diff --git a/crypto_spot_bot/config.py b/crypto_spot_bot/config.py index c32b333..1f17027 100644 --- a/crypto_spot_bot/config.py +++ b/crypto_spot_bot/config.py @@ -6,7 +6,7 @@ from pathlib import Path FIXED_SPOT_SYMBOLS = ("BTCUSDT", "ETHUSDT", "SOLUSDT") -STRATEGY_MODES = {"legacy", "trend_macd"} +STRATEGY_MODES = {"legacy", "trend_macd", "torch_forecast"} def _load_dotenv(path: Path) -> None: @@ -174,9 +174,17 @@ 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() + strategy_mode = os.getenv("STRATEGY_MODE", "torch_forecast").strip().lower() if strategy_mode not in STRATEGY_MODES: - raise ValueError("STRATEGY_MODE must be legacy or trend_macd") + raise ValueError("STRATEGY_MODE must be legacy, trend_macd or torch_forecast") + 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 + if strategy_mode == "torch_forecast": + auto_select_symbols = False + top_symbols_count = len(FIXED_SPOT_SYMBOLS) + symbols = FIXED_SPOT_SYMBOLS + forecast_enabled_default = strategy_mode == "torch_forecast" settings = Settings( trading_mode=mode, host=os.getenv("HOST", "127.0.0.1"), @@ -185,9 +193,9 @@ def load_settings(env_file: str | Path | None = None) -> Settings: 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", False), - top_symbols_count=_int_env("TOP_SYMBOLS_COUNT", len(FIXED_SPOT_SYMBOLS)), - symbols=_symbols_env("SYMBOLS") or FIXED_SPOT_SYMBOLS, + auto_select_symbols=auto_select_symbols, + top_symbols_count=top_symbols_count, + symbols=symbols, strategy_mode=strategy_mode, base_interval=os.getenv("BASE_INTERVAL", "60"), kline_limit=_int_env("KLINE_LIMIT", 240), @@ -236,7 +244,7 @@ def load_settings(env_file: str | Path | None = None) -> Settings: 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_forecast_enabled=_bool_env("TIME_SERIES_FORECAST_ENABLED", forecast_enabled_default), 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), diff --git a/crypto_spot_bot/execution.py b/crypto_spot_bot/execution.py index 8c4fb48..f35cbd9 100644 --- a/crypto_spot_bot/execution.py +++ b/crypto_spot_bot/execution.py @@ -92,7 +92,7 @@ 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: + if self.settings.strategy_mode in {"trend_macd", "torch_forecast"} and len(self.positions_for_symbol(symbol)) >= 1: return False, "DCA/усреднение отключено: позиция по паре уже открыта" dynamic_pair_limit = max( self.settings.max_positions_per_symbol, diff --git a/crypto_spot_bot/strategy.py b/crypto_spot_bot/strategy.py index 0d545c3..e3cd144 100644 --- a/crypto_spot_bot/strategy.py +++ b/crypto_spot_bot/strategy.py @@ -24,6 +24,15 @@ class SpotStrategy: account: dict | None = None, trend_candles: list[Candle] | None = None, ) -> Signal: + if self.settings.strategy_mode == "torch_forecast": + return _torch_forecast_entry_signal( + settings=self.settings, + symbol=symbol, + ticker=ticker, + open_positions_for_symbol=open_positions_for_symbol, + forecast=forecast or {}, + account=account, + ) if self.settings.strategy_mode == "trend_macd": return _trend_macd_entry_signal( settings=self.settings, @@ -354,6 +363,8 @@ class SpotStrategy: learning: dict | None = None, forecast: dict | None = None, ) -> Signal: + if self.settings.strategy_mode == "torch_forecast": + return _torch_forecast_exit_signal(self.settings, position, candles, ticker, forecast or {}) if self.settings.strategy_mode == "trend_macd": return _trend_macd_exit_signal(self.settings, position, candles, ticker) if ticker is None: @@ -600,6 +611,208 @@ def _trend_macd_exit_signal( return Signal(position.symbol, "HOLD", 0.35, "trend_macd: условия выхода не выполнены", diagnostics) +def _torch_forecast_entry_signal( + *, + settings: Settings, + symbol: str, + ticker: Ticker | None, + open_positions_for_symbol: int, + forecast: dict, + account: dict | None, +) -> Signal: + if ticker is None: + return Signal(symbol, "HOLD", 0.0, "torch_forecast: no ticker data") + if open_positions_for_symbol > 0: + return Signal(symbol, "HOLD", 0.0, "torch_forecast: position for symbol is already open") + + stop_loss_percent = _clamp(settings.stop_loss_percent, 0.003, 0.08) + sizing = _torch_forecast_position_sizing(settings, account, stop_loss_percent, forecast) + position_notional = float(sizing["notional_usdt"]) + expected_return = _safe_float(forecast.get("expected_return_percent"), 0.0) + probability_up = _safe_float(forecast.get("probability_up"), 0.5) + skill = _safe_float(forecast.get("skill"), 0.0) + min_edge = max(0.0, settings.time_series_min_edge_percent) + min_probability = _torch_min_probability(settings) + confidence = _torch_forecast_confidence(settings, forecast) + spread_ok = ticker.spread_percent <= settings.max_spread_percent + liquidity_ok = ticker.turnover_24h >= settings.min_24h_turnover_usdt + model_ok = _is_torch_forecast(forecast) + checks = { + "torch_model_ok": model_ok, + "forecast_usable": bool(forecast.get("usable", False)), + "forecast_not_blocked": not bool(forecast.get("block_entry", False)), + "expected_edge_ok": expected_return >= min_edge, + "probability_ok": probability_up >= min_probability, + "skill_ok": skill > 0.0, + "confidence_ok": confidence >= settings.min_signal_confidence, + "spread_ok": spread_ok, + "liquidity_ok": liquidity_ok, + "risk_size_ok": position_notional >= settings.min_position_usdt, + } + diagnostics = { + "strategy_mode": "torch_forecast", + "trade_mode": "TORCH_FORECAST", + "forecast": forecast, + "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), + "expected_return_percent": expected_return, + "min_edge_percent": min_edge, + "probability_up": probability_up, + "min_probability_up": min_probability, + "skill": skill, + "spread_percent": round(ticker.spread_percent, 5), + "turnover_24h": ticker.turnover_24h, + "checks": checks, + "grid": {"enabled": False, "active": False}, + "rebound": {"enabled": False, "active": False}, + "learning": {}, + "llm": {}, + } + if all(checks.values()): + return Signal( + symbol, + "BUY", + confidence, + ( + "torch_forecast: PyTorch edge confirmed; " + f"model={forecast.get('model')}, p_up={probability_up:.3f}, " + f"expected={expected_return:.4f}%, size={position_notional:.2f} USDT" + ), + diagnostics, + ) + failed = ", ".join(name for name, ok in checks.items() if not ok) + return Signal(symbol, "HOLD", confidence, f"torch_forecast: entry blocked ({failed})", diagnostics) + + +def _torch_forecast_exit_signal( + settings: Settings, + position: Position, + candles: list[Candle], + ticker: Ticker | None, + forecast: dict, +) -> Signal: + if ticker is None: + return Signal(position.symbol, "HOLD", 0.0, "torch_forecast: no ticker data for exit") + + latest = candles[-1] if candles else None + 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 and 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) + + expected_return = _safe_float(forecast.get("expected_return_percent"), 0.0) + probability_up = _safe_float(forecast.get("probability_up"), 0.5) + skill = _safe_float(forecast.get("skill"), 0.0) + min_edge = max(0.0, settings.time_series_min_edge_percent) + min_probability = _torch_min_probability(settings) + estimated_exit_net_percent = _estimated_exit_net_percent(position, price, settings) + diagnostics = { + "strategy_mode": "torch_forecast", + "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, + "forecast": forecast, + "expected_return_percent": expected_return, + "min_edge_percent": min_edge, + "probability_up": probability_up, + "min_probability_up": min_probability, + "skill": skill, + "estimated_exit_net_percent": round(estimated_exit_net_percent, 4), + "atr_14": latest.atr_14 if latest else None, + } + if price <= effective_stop_loss: + return Signal(position.symbol, "SELL", 1.0, "torch_forecast: stop-loss hit", diagnostics) + if atr_trailing_stop is not None and price <= atr_trailing_stop: + return Signal(position.symbol, "SELL", 0.94, "torch_forecast: ATR trailing stop hit", diagnostics) + if not _is_torch_forecast(forecast): + return Signal(position.symbol, "SELL", 0.78, "torch_forecast: no valid PyTorch forecast to hold", diagnostics) + if bool(forecast.get("block_entry", False)) or expected_return <= 0.0 or probability_up <= 0.50: + return Signal( + position.symbol, + "SELL", + 0.86, + ( + "torch_forecast: PyTorch forecast turned negative; " + f"p_up={probability_up:.3f}, expected={expected_return:.4f}%" + ), + diagnostics, + ) + weak_hold = expected_return < min_edge or probability_up < min_probability or skill <= 0.0 + if weak_hold and estimated_exit_net_percent >= 0: + return Signal( + position.symbol, + "SELL", + 0.74, + ( + "torch_forecast: PyTorch no longer confirms enough edge; " + f"p_up={probability_up:.3f}, expected={expected_return:.4f}%" + ), + diagnostics, + ) + return Signal(position.symbol, "HOLD", 0.35, "torch_forecast: PyTorch hold confirmed", diagnostics) + + +def _is_torch_forecast(forecast: dict) -> bool: + model = str(forecast.get("model", "")).strip().lower() + return bool(forecast.get("usable", False)) and model in {"torch_lstm", "torch_gru"} + + +def _torch_min_probability(settings: Settings) -> float: + return round(_clamp(settings.min_signal_confidence - 0.08, 0.52, 0.68), 4) + + +def _torch_forecast_confidence(settings: Settings, forecast: dict) -> float: + expected_return = max(0.0, _safe_float(forecast.get("expected_return_percent"), 0.0)) + probability_up = _safe_float(forecast.get("probability_up"), 0.5) + skill = max(0.0, _safe_float(forecast.get("skill"), 0.0)) + min_edge = max(0.01, settings.time_series_min_edge_percent) + edge_strength = _clamp(expected_return / max(min_edge * 4.0, 0.01), 0.0, 1.0) + probability_strength = _clamp((probability_up - 0.50) / 0.25, 0.0, 1.0) + skill_strength = _clamp(skill / 0.35, 0.0, 1.0) + confidence = 0.45 + probability_strength * 0.30 + edge_strength * 0.20 + skill_strength * 0.10 + return round(_clamp(confidence, 0.0, 0.96), 4) + + +def _torch_forecast_position_sizing( + settings: Settings, + account: dict | None, + stop_loss_percent: float, + forecast: dict, +) -> dict[str, float | str]: + base = _trend_position_sizing(settings, account, stop_loss_percent) + base_notional = float(base["notional_usdt"]) + if base_notional <= 0: + notional = 0.0 + edge_multiplier = probability_multiplier = skill_multiplier = 0.0 + else: + expected_return = max(0.0, _safe_float(forecast.get("expected_return_percent"), 0.0)) + probability_up = _safe_float(forecast.get("probability_up"), 0.5) + skill = max(0.0, _safe_float(forecast.get("skill"), 0.0)) + min_edge = max(0.01, settings.time_series_min_edge_percent) + edge_multiplier = _clamp(expected_return / max(min_edge * 3.0, 0.01), 0.25, 1.15) + probability_multiplier = _clamp(0.75 + (probability_up - 0.55) * 3.0, 0.50, 1.20) + skill_multiplier = _clamp(0.85 + skill * 0.60, 0.60, 1.15) + raw = base_notional * edge_multiplier * probability_multiplier * skill_multiplier + notional = 0.0 if raw < settings.min_position_usdt else min(raw, settings.max_position_usdt) + return { + **base, + "method": "torch_forecast_risk", + "notional_usdt": round(notional, 2), + "base_notional_usdt": base["notional_usdt"], + "torch_edge_multiplier": round(edge_multiplier, 4), + "torch_probability_multiplier": round(probability_multiplier, 4), + "torch_skill_multiplier": round(skill_multiplier, 4), + } + + def _has_trend_entry_indicators(current: Candle, previous: Candle, trend: Candle) -> bool: return all( value is not None diff --git a/tests/test_config.py b/tests/test_config.py index 4dfed02..74fba83 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -77,7 +77,40 @@ def test_default_symbols_are_fixed_trend_pairs(tmp_path, monkeypatch) -> None: assert settings.auto_select_symbols is False assert settings.top_symbols_count == 3 assert settings.symbols == FIXED_SPOT_SYMBOLS - assert settings.strategy_mode == "trend_macd" + assert settings.strategy_mode == "torch_forecast" assert settings.base_interval == "60" assert settings.trend_interval == "D" assert settings.risk_per_trade_percent == 0.01 + assert settings.time_series_forecast_enabled is True + + +def test_torch_forecast_forces_fixed_three_symbols(tmp_path, monkeypatch) -> None: + for key in ( + "AUTO_SELECT_SYMBOLS", + "TOP_SYMBOLS_COUNT", + "SYMBOLS", + "STRATEGY_MODE", + "TIME_SERIES_FORECAST_ENABLED", + ): + monkeypatch.delenv(key, raising=False) + monkeypatch.setenv("TRADING_MODE", "paper") + env_file = tmp_path / ".env" + env_file.write_text( + "\n".join( + [ + "TRADING_MODE=paper", + "STRATEGY_MODE=torch_forecast", + "AUTO_SELECT_SYMBOLS=true", + "TOP_SYMBOLS_COUNT=9", + "SYMBOLS=DOGEUSDT,XRPUSDT", + ] + ), + encoding="utf-8", + ) + + settings = load_settings(env_file) + + assert settings.auto_select_symbols is False + assert settings.top_symbols_count == 3 + assert settings.symbols == FIXED_SPOT_SYMBOLS + assert settings.time_series_forecast_enabled is True diff --git a/tests/test_execution.py b/tests/test_execution.py index 61cd8b7..f889975 100644 --- a/tests/test_execution.py +++ b/tests/test_execution.py @@ -160,6 +160,31 @@ def test_trend_macd_broker_blocks_dca_for_same_symbol(make_settings, tmp_path) - assert len(broker.open_positions()) == 1 +def test_torch_forecast_broker_blocks_dca_for_same_symbol(make_settings, tmp_path) -> None: + settings = make_settings( + tmp_path, + strategy_mode="torch_forecast", + 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 + + def test_trend_macd_closes_old_paper_positions_outside_symbol_universe(make_settings, tmp_path) -> None: settings = make_settings( tmp_path, diff --git a/tests/test_strategy.py b/tests/test_strategy.py index eb4c84e..818f6d6 100644 --- a/tests/test_strategy.py +++ b/tests/test_strategy.py @@ -233,6 +233,81 @@ def test_trend_macd_exits_on_atr_trailing_stop(make_settings, tmp_path) -> None: assert "ATR trailing" in signal.reason +def test_torch_forecast_buys_only_from_positive_torch_edge(make_settings, tmp_path) -> None: + settings = make_settings( + tmp_path, + strategy_mode="torch_forecast", + max_position_usdt=25, + 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", + [], + ticker, + open_positions_for_symbol=0, + forecast={ + "usable": True, + "model": "torch_gru", + "expected_return_percent": 0.24, + "probability_up": 0.63, + "skill": 0.22, + "block_entry": False, + }, + account={"equity": 100.0}, + ) + + assert signal.action == "BUY" + assert signal.diagnostics["strategy_mode"] == "torch_forecast" + assert signal.diagnostics["checks"]["torch_model_ok"] is True + assert signal.diagnostics["position_notional_usdt"] == 25.0 + + +def test_torch_forecast_blocks_without_valid_torch_model(make_settings, tmp_path) -> None: + settings = make_settings(tmp_path, strategy_mode="torch_forecast") + 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, + forecast={"usable": True, "model": "none", "expected_return_percent": 0.5, "probability_up": 0.7}, + account={"equity": 100.0}, + ) + + assert signal.action == "HOLD" + assert signal.diagnostics["checks"]["torch_model_ok"] is False + + +def test_torch_forecast_exits_when_forecast_turns_negative(make_settings, tmp_path) -> None: + settings = make_settings(tmp_path, strategy_mode="torch_forecast", stop_loss_percent=0.04) + strategy = SpotStrategy(settings) + position = Position(1, "SOLUSDT", 1, 100, 100, 0.1, 96, 120, 103) + ticker = Ticker("SOLUSDT", 101, 100.99, 101.01, 10_000_000, 1000, 1.0) + + signal = strategy.exit_signal( + position, + _trend_entry_candles(), + ticker, + forecast={ + "usable": True, + "model": "torch_lstm", + "expected_return_percent": -0.08, + "probability_up": 0.43, + "skill": 0.18, + "block_entry": True, + }, + ) + + assert signal.action == "SELL" + assert "torch_forecast" 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)