diff --git a/crypto_spot_bot/strategy.py b/crypto_spot_bot/strategy.py index af3017d..2f807bf 100644 --- a/crypto_spot_bot/strategy.py +++ b/crypto_spot_bot/strategy.py @@ -901,6 +901,9 @@ def _torch_forecast_exit_signal( "estimated_exit_net_percent": round(estimated_exit_net_percent, 4), "atr_14": latest.atr_14 if latest else None, } + hold_seconds = (utc_now() - position.opened_at).total_seconds() + diagnostics["hold_seconds"] = hold_seconds + diagnostics["min_hold_seconds"] = settings.min_hold_seconds if effective_stop_loss is not None and price <= effective_stop_loss: return Signal(position.symbol, "SELL", 1.0, "torch_forecast: stop-loss hit", diagnostics) if price >= position.take_profit: @@ -928,12 +931,33 @@ def _torch_forecast_exit_signal( ) 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: + if hold_seconds < settings.min_hold_seconds: + diagnostics["forecast_exit_blocked_by_min_hold"] = True + return Signal( + position.symbol, + "HOLD", + 0.46, + "torch_forecast: minimum hold protects against fee churn", + diagnostics, + ) + forecast_exit = _forecast_exit_signal( + forecast=forecast, + position=position, + price=price, + estimated_exit_net_percent=estimated_exit_net_percent, + stop_loss_percent=stop_loss_percent, + min_edge_percent=min_edge, + ) + if forecast_exit is not None: + action, confidence, reason = forecast_exit + return Signal(position.symbol, action, confidence, reason, diagnostics) + diagnostics["forecast_exit_blocked_by_cost"] = True return Signal( position.symbol, - "SELL", - 0.86, + "HOLD", + 0.44, ( - "torch_forecast: PyTorch forecast turned negative; " + "torch_forecast: forecast weakened, but exit is not worth fees; " f"p_up={probability_up:.3f}, expected={expected_return:.4f}%" ), diagnostics, diff --git a/tests/test_strategy.py b/tests/test_strategy.py index fe0b371..798fd37 100644 --- a/tests/test_strategy.py +++ b/tests/test_strategy.py @@ -826,7 +826,18 @@ def test_torch_forecast_rebound_fallback_can_be_disabled(make_settings, tmp_path 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) + position = Position( + 1, + "SOLUSDT", + 1, + 100, + 100, + 0.1, + 96, + 120, + 103, + opened_at=utc_now() - timedelta(seconds=600), + ) ticker = Ticker("SOLUSDT", 101, 100.99, 101.01, 10_000_000, 1000, 1.0) signal = strategy.exit_signal( @@ -844,7 +855,66 @@ def test_torch_forecast_exits_when_forecast_turns_negative(make_settings, tmp_pa ) assert signal.action == "SELL" - assert "torch_forecast" in signal.reason + assert "прогноз" in signal.reason + + +def test_torch_forecast_holds_negative_forecast_during_min_hold(make_settings, tmp_path) -> None: + settings = make_settings(tmp_path, strategy_mode="torch_forecast", min_hold_seconds=180) + 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 == "HOLD" + assert signal.diagnostics["forecast_exit_blocked_by_min_hold"] is True + + +def test_torch_forecast_holds_fee_churn_exit_after_min_hold(make_settings, tmp_path) -> None: + settings = make_settings(tmp_path, strategy_mode="torch_forecast", min_hold_seconds=60) + strategy = SpotStrategy(settings) + position = Position( + 1, + "BTCUSDT", + 1, + 100, + 100, + 0.1, + 96, + 120, + 100.1, + opened_at=utc_now() - timedelta(seconds=600), + ) + ticker = Ticker("BTCUSDT", 99.99, 99.98, 100.0, 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.01, + "probability_up": 0.499, + "skill": 0.18, + "block_entry": False, + }, + ) + + assert signal.action == "HOLD" + assert signal.diagnostics["forecast_exit_blocked_by_cost"] is True def test_torch_forecast_rebound_fallback_holds_without_model(make_settings, tmp_path) -> None: