diff --git a/crypto_spot_bot/config.py b/crypto_spot_bot/config.py index 4db827e..9889da1 100644 --- a/crypto_spot_bot/config.py +++ b/crypto_spot_bot/config.py @@ -142,6 +142,7 @@ class Settings: take_profit_percent: float trailing_stop_percent: float min_hold_seconds: int + min_exit_net_percent: float entry_cooldown_seconds: int max_daily_drawdown_usdt: float min_cash_reserve_usdt: float @@ -294,6 +295,7 @@ def load_settings(env_file: str | Path | None = None) -> Settings: 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), + min_exit_net_percent=_float_env("MIN_EXIT_NET_PERCENT", 0.20), entry_cooldown_seconds=_int_env("ENTRY_COOLDOWN_SECONDS", 180), max_daily_drawdown_usdt=_float_env("MAX_DAILY_DRAWDOWN_USDT", 6.0), min_cash_reserve_usdt=_float_env("MIN_CASH_RESERVE_USDT", 5.0), diff --git a/crypto_spot_bot/dashboard.py b/crypto_spot_bot/dashboard.py index eb70f62..365af5b 100644 --- a/crypto_spot_bot/dashboard.py +++ b/crypto_spot_bot/dashboard.py @@ -323,6 +323,7 @@ def _safe_config(settings: Settings) -> dict[str, Any]: "take_profit_percent": settings.take_profit_percent, "trailing_stop_percent": settings.trailing_stop_percent, "min_hold_seconds": settings.min_hold_seconds, + "min_exit_net_percent": settings.min_exit_net_percent, "entry_cooldown_seconds": settings.entry_cooldown_seconds, "max_daily_drawdown_usdt": settings.max_daily_drawdown_usdt, "min_cash_reserve_usdt": settings.min_cash_reserve_usdt, diff --git a/crypto_spot_bot/strategy.py b/crypto_spot_bot/strategy.py index 1d6c601..cf4f4ac 100644 --- a/crypto_spot_bot/strategy.py +++ b/crypto_spot_bot/strategy.py @@ -394,6 +394,7 @@ class SpotStrategy: effective_take_profit = position.entry_price * (1 + take_profit_percent) trailing = position.trailing_stop(trailing_percent) estimated_exit_net_percent = _estimated_exit_net_percent(position, price, self.settings) + min_exit_net_percent = _min_exit_net_percent(self.settings) diagnostics = { "price": price, "entry_price": position.entry_price, @@ -408,6 +409,7 @@ class SpotStrategy: "adaptive_rules": adaptive, "forecast": forecast, "estimated_exit_net_percent": round(estimated_exit_net_percent, 4), + "min_exit_net_percent": min_exit_net_percent, "min_exit_profit_percent": float(adaptive.get("min_exit_profit_percent", 0.0) or 0.0), } if effective_stop_loss is not None and price <= effective_stop_loss: @@ -438,6 +440,7 @@ class SpotStrategy: estimated_exit_net_percent=estimated_exit_net_percent, stop_loss_percent=stop_loss_percent, min_edge_percent=self.settings.time_series_min_edge_percent, + min_exit_net_percent=min_exit_net_percent, ) if forecast_exit is not None: action, confidence, reason = forecast_exit @@ -876,6 +879,7 @@ def _torch_forecast_exit_signal( 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) + min_exit_net_percent = _min_exit_net_percent(settings) entry_path = str(position.entry_diagnostics.get("entry_path", "")) entry_edge_mode = str(position.entry_diagnostics.get("edge_mode", "")) rebound_fallback_position = entry_path == "rebound_fallback" or entry_edge_mode == "rebound_fallback" @@ -899,6 +903,7 @@ def _torch_forecast_exit_signal( "min_probability_up": min_probability, "skill": skill, "estimated_exit_net_percent": round(estimated_exit_net_percent, 4), + "min_exit_net_percent": min_exit_net_percent, "atr_14": latest.atr_14 if latest else None, } hold_seconds = (utc_now() - position.opened_at).total_seconds() @@ -909,13 +914,15 @@ def _torch_forecast_exit_signal( if price >= position.take_profit: return Signal(position.symbol, "SELL", 0.96, "torch_forecast: take-profit hit", diagnostics) if atr_trailing_stop is not None and price <= atr_trailing_stop: - if estimated_exit_net_percent < 0: - diagnostics["atr_exit_blocked_by_cost"] = True + if estimated_exit_net_percent < min_exit_net_percent: + diagnostics["atr_exit_blocked_by_min_profit"] = True + if estimated_exit_net_percent < 0: + diagnostics["atr_exit_blocked_by_cost"] = True return Signal( position.symbol, "HOLD", 0.45, - "torch_forecast: ATR trailing touched, but exit is not worth fees", + "torch_forecast: ATR trailing touched, but exit profit is below minimum", diagnostics, ) return Signal(position.symbol, "SELL", 0.94, "torch_forecast: ATR trailing stop hit", diagnostics) @@ -956,23 +963,26 @@ def _torch_forecast_exit_signal( estimated_exit_net_percent=estimated_exit_net_percent, stop_loss_percent=stop_loss_percent, min_edge_percent=min_edge, + min_exit_net_percent=min_exit_net_percent, ) 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 + diagnostics["forecast_exit_blocked_by_min_profit"] = True + if estimated_exit_net_percent < 0: + diagnostics["forecast_exit_blocked_by_cost"] = True return Signal( position.symbol, "HOLD", 0.44, ( - "torch_forecast: forecast weakened, but exit is not worth fees; " + "torch_forecast: forecast weakened, but exit profit is below minimum; " 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: + if weak_hold and estimated_exit_net_percent >= min_exit_net_percent: return Signal( position.symbol, "SELL", @@ -983,6 +993,8 @@ def _torch_forecast_exit_signal( ), diagnostics, ) + if weak_hold and estimated_exit_net_percent >= 0: + diagnostics["weak_exit_blocked_by_min_profit"] = True return Signal(position.symbol, "HOLD", 0.35, "torch_forecast: PyTorch hold confirmed", diagnostics) @@ -1633,6 +1645,10 @@ def _estimated_exit_net_percent(position: Position, price: float, settings: Sett return gross_percent - round_trip_cost_percent +def _min_exit_net_percent(settings: Settings) -> float: + return round(_clamp(settings.min_exit_net_percent, 0.0, 5.0), 4) + + def _adaptive_indicator_exit_allowed(adaptive: dict, mode_key: str, estimated_exit_net_percent: float) -> bool: mode = str(adaptive.get(mode_key, "normal")).lower() if mode != "profit_only": @@ -1649,6 +1665,7 @@ def _forecast_exit_signal( estimated_exit_net_percent: float, stop_loss_percent: float, min_edge_percent: float, + min_exit_net_percent: float, ) -> tuple[str, float, str] | None: if not forecast.get("usable"): return None @@ -1660,7 +1677,7 @@ def _forecast_exit_signal( if not strong_negative: return None reason = forecast.get("reason") or "ожидается снижение" - if estimated_exit_net_percent >= 0: + if estimated_exit_net_percent >= min_exit_net_percent: return "SELL", 0.82, f"прогноз временного ряда ухудшился: {reason}; фиксируем результат" loss_from_entry = ((price - position.entry_price) / position.entry_price) if position.entry_price else 0.0 soft_loss_limit = -max(0.003, stop_loss_percent * 0.35) diff --git a/tests/conftest.py b/tests/conftest.py index 803f4fe..a6feb13 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -94,6 +94,7 @@ def make_settings(): take_profit_percent=0.035, trailing_stop_percent=0.015, min_hold_seconds=180, + min_exit_net_percent=0.20, entry_cooldown_seconds=180, max_daily_drawdown_usdt=6.0, min_cash_reserve_usdt=5.0, diff --git a/tests/test_strategy.py b/tests/test_strategy.py index 8df6376..5377ce7 100644 --- a/tests/test_strategy.py +++ b/tests/test_strategy.py @@ -954,6 +954,81 @@ def test_torch_forecast_holds_atr_trailing_exit_that_does_not_cover_fees(make_se assert signal.diagnostics["atr_exit_blocked_by_cost"] is True +def test_torch_forecast_holds_atr_trailing_exit_below_min_profit(make_settings, tmp_path) -> None: + settings = make_settings(tmp_path, strategy_mode="torch_forecast", min_hold_seconds=60, min_exit_net_percent=0.20) + strategy = SpotStrategy(settings) + candles = _trend_entry_candles(close=100.35) + candles[-1].atr_14 = 0.6 + position = Position( + 1, + "MNTUSDT", + 1, + 100, + 100, + 0.1, + 96, + 120, + 102, + opened_at=utc_now() - timedelta(seconds=600), + ) + ticker = Ticker("MNTUSDT", 100.35, 100.34, 100.36, 10_000_000, 1000, 1.0) + + signal = strategy.exit_signal( + position, + candles, + ticker, + forecast={ + "usable": True, + "model": "torch_lstm", + "expected_return_percent": 0.4, + "probability_up": 0.58, + "skill": 0.18, + "block_entry": False, + }, + ) + + assert signal.action == "HOLD" + assert signal.diagnostics["atr_exit_blocked_by_min_profit"] is True + assert signal.diagnostics["estimated_exit_net_percent"] < settings.min_exit_net_percent + + +def test_torch_forecast_holds_negative_forecast_exit_below_min_profit(make_settings, tmp_path) -> None: + settings = make_settings(tmp_path, strategy_mode="torch_forecast", min_hold_seconds=60, min_exit_net_percent=0.20) + strategy = SpotStrategy(settings) + position = Position( + 1, + "BTCUSDT", + 1, + 100, + 100, + 0.1, + 96, + 120, + 100.5, + opened_at=utc_now() - timedelta(seconds=600), + ) + ticker = Ticker("BTCUSDT", 100.35, 100.34, 100.36, 10_000_000, 1000, 1.0) + + signal = strategy.exit_signal( + position, + _trend_entry_candles(close=100.35), + ticker, + forecast={ + "usable": True, + "model": "torch_lstm", + "expected_return_percent": -0.2, + "probability_up": 0.40, + "skill": 0.18, + "block_entry": False, + "reason": "model turned down", + }, + ) + + assert signal.action == "HOLD" + assert signal.diagnostics["forecast_exit_blocked_by_min_profit"] is True + assert signal.diagnostics["estimated_exit_net_percent"] < settings.min_exit_net_percent + + def test_torch_forecast_rebound_fallback_holds_without_model(make_settings, tmp_path) -> None: settings = make_settings(tmp_path, strategy_mode="torch_forecast", min_hold_seconds=180) strategy = SpotStrategy(settings)