diff --git a/.env b/.env index 9dad9e6..c8a9520 100644 --- a/.env +++ b/.env @@ -74,6 +74,7 @@ TIME_SERIES_PROBE_MIN_PROBABILITY_UP=0.55 TIME_SERIES_PROBE_SIZE_MULTIPLIER=0.40 TIME_SERIES_REBOUND_FALLBACK_ENABLED=true STOP_LOSS_PERCENT=0.04 +STOP_LOSS_EXIT_ENABLED=false TAKE_PROFIT_PERCENT=0.035 TRAILING_STOP_PERCENT=0.015 MIN_HOLD_SECONDS=180 diff --git a/crypto_spot_bot/config.py b/crypto_spot_bot/config.py index 3e2ebf2..4db827e 100644 --- a/crypto_spot_bot/config.py +++ b/crypto_spot_bot/config.py @@ -138,6 +138,7 @@ class Settings: time_series_probe_size_multiplier: float time_series_rebound_fallback_enabled: bool stop_loss_percent: float + stop_loss_exit_enabled: bool take_profit_percent: float trailing_stop_percent: float min_hold_seconds: int @@ -289,6 +290,7 @@ def load_settings(env_file: str | Path | None = None) -> Settings: time_series_probe_size_multiplier=_float_env("TIME_SERIES_PROBE_SIZE_MULTIPLIER", 0.40), time_series_rebound_fallback_enabled=_bool_env("TIME_SERIES_REBOUND_FALLBACK_ENABLED", True), stop_loss_percent=_float_env("STOP_LOSS_PERCENT", 0.04), + stop_loss_exit_enabled=_bool_env("STOP_LOSS_EXIT_ENABLED", True), 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), diff --git a/crypto_spot_bot/dashboard.py b/crypto_spot_bot/dashboard.py index 90ab9b8..28b344e 100644 --- a/crypto_spot_bot/dashboard.py +++ b/crypto_spot_bot/dashboard.py @@ -314,6 +314,7 @@ def _safe_config(settings: Settings) -> dict[str, Any]: "time_series_rebound_fallback_enabled": settings.time_series_rebound_fallback_enabled, "time_series_model_artifact": _time_series_model_artifact(settings), "stop_loss_percent": settings.stop_loss_percent, + "stop_loss_exit_enabled": settings.stop_loss_exit_enabled, "take_profit_percent": settings.take_profit_percent, "trailing_stop_percent": settings.trailing_stop_percent, "min_hold_seconds": settings.min_hold_seconds, diff --git a/crypto_spot_bot/learning.py b/crypto_spot_bot/learning.py index 94797c1..14a38bb 100644 --- a/crypto_spot_bot/learning.py +++ b/crypto_spot_bot/learning.py @@ -299,6 +299,7 @@ def _neutral_rules(settings: Settings, reason: str | None = None) -> dict[str, A "ema_exit_mode": "normal", "rsi_exit_mode": "normal", "stop_loss_percent": settings.stop_loss_percent, + "stop_loss_exit_enabled": settings.stop_loss_exit_enabled, "take_profit_percent": settings.take_profit_percent, "trailing_stop_percent": settings.trailing_stop_percent, "symbol_threshold_adjustments": {}, diff --git a/crypto_spot_bot/strategy.py b/crypto_spot_bot/strategy.py index f0d6b45..af3017d 100644 --- a/crypto_spot_bot/strategy.py +++ b/crypto_spot_bot/strategy.py @@ -320,7 +320,8 @@ class SpotStrategy: diagnostics = { "price": price, "entry_price": position.entry_price, - "stop_loss": position.stop_loss, + "stop_loss": position.stop_loss if self.settings.stop_loss_exit_enabled else None, + "stop_loss_exit_enabled": self.settings.stop_loss_exit_enabled, "take_profit": position.take_profit, "highest_price": position.highest_price, "trailing_stop": trailing, @@ -328,7 +329,7 @@ class SpotStrategy: "ema_20": latest.ema_20, "ema_50": latest.ema_50, } - if price <= position.stop_loss: + if self.settings.stop_loss_exit_enabled and price <= position.stop_loss: return Signal(position.symbol, "SELL", 1.0, "сработал стоп-лосс", diagnostics) if price >= position.take_profit: return Signal(position.symbol, "SELL", 0.96, "сработал тейк-профит", diagnostics) @@ -389,7 +390,7 @@ class SpotStrategy: trailing_percent = _adaptive_percent( adaptive, "trailing_stop_percent", self.settings.trailing_stop_percent, 0.003, 0.08 ) - effective_stop_loss = max(position.stop_loss, position.entry_price * (1 - stop_loss_percent)) + effective_stop_loss = _effective_stop_loss(self.settings, position, stop_loss_percent) 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) @@ -397,6 +398,7 @@ class SpotStrategy: "price": price, "entry_price": position.entry_price, "stop_loss": effective_stop_loss, + "stop_loss_exit_enabled": self.settings.stop_loss_exit_enabled, "take_profit": effective_take_profit, "highest_price": position.highest_price, "trailing_stop": trailing, @@ -408,7 +410,7 @@ class SpotStrategy: "estimated_exit_net_percent": round(estimated_exit_net_percent, 4), "min_exit_profit_percent": float(adaptive.get("min_exit_profit_percent", 0.0) or 0.0), } - if price <= effective_stop_loss: + if effective_stop_loss is not None and price <= effective_stop_loss: return Signal(position.symbol, "SELL", 1.0, "сработал стоп-лосс", diagnostics) if price >= effective_take_profit: return Signal(position.symbol, "SELL", 0.96, "сработал тейк-профит", diagnostics) @@ -580,11 +582,9 @@ def _trend_macd_exit_signal( previous = candles[-2] 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)) + effective_stop_loss = _effective_stop_loss(settings, position, stop_loss_percent) atr_multiplier = _clamp(settings.atr_trailing_multiplier, 0.5, 10.0) - atr_trailing_stop = None - if 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) + atr_trailing_stop = _atr_trailing_stop(settings, position, latest.atr_14, atr_multiplier, effective_stop_loss) macd_cross_down = _macd_crossed_down(previous, latest) close_below_ema50 = latest.ema_50 is not None and latest.close < latest.ema_50 diagnostics = { @@ -592,6 +592,7 @@ def _trend_macd_exit_signal( "price": price, "entry_price": position.entry_price, "stop_loss": effective_stop_loss, + "stop_loss_exit_enabled": settings.stop_loss_exit_enabled, "atr_trailing_stop": atr_trailing_stop, "atr_trailing_multiplier": atr_multiplier, "highest_price": position.highest_price, @@ -603,7 +604,7 @@ def _trend_macd_exit_signal( "macd_cross_down": macd_cross_down, "close_below_ema50": close_below_ema50, } - if price <= effective_stop_loss: + if effective_stop_loss is not None and price <= effective_stop_loss: return Signal(position.symbol, "SELL", 1.0, "trend_macd: сработал стоп-лосс", diagnostics) if atr_trailing_stop is not None and price <= atr_trailing_stop: return Signal(position.symbol, "SELL", 0.94, "trend_macd: сработал ATR trailing stop", diagnostics) @@ -859,11 +860,15 @@ def _torch_forecast_exit_signal( 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)) + effective_stop_loss = _effective_stop_loss(settings, position, 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) + atr_trailing_stop = _atr_trailing_stop( + settings, + position, + latest.atr_14 if latest else None, + atr_multiplier, + effective_stop_loss, + ) expected_return = _safe_float(forecast.get("expected_return_percent"), 0.0) probability_up = _safe_float(forecast.get("probability_up"), 0.5) @@ -879,6 +884,7 @@ def _torch_forecast_exit_signal( "price": price, "entry_price": position.entry_price, "stop_loss": effective_stop_loss, + "stop_loss_exit_enabled": settings.stop_loss_exit_enabled, "take_profit": position.take_profit, "atr_trailing_stop": atr_trailing_stop, "atr_trailing_multiplier": atr_multiplier, @@ -895,7 +901,7 @@ def _torch_forecast_exit_signal( "estimated_exit_net_percent": round(estimated_exit_net_percent, 4), "atr_14": latest.atr_14 if latest else None, } - if price <= effective_stop_loss: + 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: return Signal(position.symbol, "SELL", 0.96, "torch_forecast: take-profit hit", diagnostics) @@ -1565,6 +1571,27 @@ def _adaptive_percent(adaptive: dict, key: str, default: float, low: float, high return _clamp(_safe_float(adaptive.get(key), default), low, high) +def _effective_stop_loss(settings: Settings, position: Position, stop_loss_percent: float) -> float | None: + if not settings.stop_loss_exit_enabled: + return None + return max(position.stop_loss, position.entry_price * (1 - stop_loss_percent)) + + +def _atr_trailing_stop( + settings: Settings, + position: Position, + atr: float | None, + atr_multiplier: float, + effective_stop_loss: float | None, +) -> float | None: + if atr is None or atr <= 0 or position.highest_price <= position.entry_price: + return None + raw_stop = position.highest_price - atr * atr_multiplier + if settings.stop_loss_exit_enabled and effective_stop_loss is not None: + return max(effective_stop_loss, raw_stop) + return raw_stop if raw_stop > position.entry_price else None + + def _estimated_exit_net_percent(position: Position, price: float, settings: Settings) -> float: if position.entry_price <= 0: return 0.0 diff --git a/tests/conftest.py b/tests/conftest.py index 2f1cb6a..803f4fe 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -90,6 +90,7 @@ def make_settings(): time_series_probe_size_multiplier=0.40, time_series_rebound_fallback_enabled=True, stop_loss_percent=0.02, + stop_loss_exit_enabled=True, take_profit_percent=0.035, trailing_stop_percent=0.015, min_hold_seconds=180, diff --git a/tests/test_strategy.py b/tests/test_strategy.py index 1208876..fe0b371 100644 --- a/tests/test_strategy.py +++ b/tests/test_strategy.py @@ -233,6 +233,90 @@ def test_trend_macd_exits_on_atr_trailing_stop(make_settings, tmp_path) -> None: assert "ATR trailing" in signal.reason +def test_trend_macd_holds_below_stop_when_stop_loss_exit_disabled(make_settings, tmp_path) -> None: + settings = make_settings( + tmp_path, + strategy_mode="trend_macd", + stop_loss_exit_enabled=False, + atr_trailing_multiplier=2.2, + ) + strategy = SpotStrategy(settings) + candles = _trend_entry_candles(close=99.0, ema50=95.0, macd_cross_up=False) + candles[-2].macd = 0.1 + candles[-2].macd_signal = 0.0 + candles[-1].macd = 0.1 + candles[-1].macd_signal = 0.0 + candles[-1].atr_14 = 1.0 + position = Position(1, "BTCUSDT", 1, 100, 100, 0.1, 96, 120, 100.5) + ticker = Ticker("BTCUSDT", 95.5, 95.49, 95.51, 1_000_000, 100, 0) + + signal = strategy.exit_signal(position, candles, ticker) + + assert signal.action == "HOLD" + assert signal.diagnostics["stop_loss"] is None + assert signal.diagnostics["stop_loss_exit_enabled"] is False + + +def test_torch_atr_trailing_without_stop_loss_waits_for_profit_stop(make_settings, tmp_path) -> None: + settings = make_settings( + tmp_path, + strategy_mode="torch_forecast", + stop_loss_exit_enabled=False, + atr_trailing_multiplier=2.2, + ) + strategy = SpotStrategy(settings) + candles = _trend_entry_candles(close=99.0, ema50=95.0, macd_cross_up=False) + candles[-1].atr_14 = 1.0 + position = Position(1, "BTCUSDT", 1, 100, 100, 0.1, 96, 120, 101.0) + ticker = Ticker("BTCUSDT", 98.7, 98.69, 98.71, 1_000_000, 100, 0) + + signal = strategy.exit_signal( + position, + candles, + ticker, + forecast={ + "usable": True, + "model": "torch_lstm", + "expected_return_percent": 0.4, + "probability_up": 0.62, + "skill": 0.18, + }, + ) + + assert signal.action == "HOLD" + assert signal.diagnostics["atr_trailing_stop"] is None + + +def test_torch_atr_trailing_without_stop_loss_can_lock_profit(make_settings, tmp_path) -> None: + settings = make_settings( + tmp_path, + strategy_mode="torch_forecast", + stop_loss_exit_enabled=False, + atr_trailing_multiplier=2.2, + ) + strategy = SpotStrategy(settings) + candles = _trend_entry_candles(close=104.0, ema50=95.0, macd_cross_up=False) + candles[-1].atr_14 = 1.0 + position = Position(1, "BTCUSDT", 1, 100, 100, 0.1, 96, 120, 104.0) + ticker = Ticker("BTCUSDT", 101.7, 101.69, 101.71, 1_000_000, 100, 0) + + signal = strategy.exit_signal( + position, + candles, + ticker, + forecast={ + "usable": True, + "model": "torch_lstm", + "expected_return_percent": 0.4, + "probability_up": 0.62, + "skill": 0.18, + }, + ) + + assert signal.action == "SELL" + 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, diff --git a/tools/calibrate_torch_thresholds.py b/tools/calibrate_torch_thresholds.py index cc6a0c0..a866ebe 100644 --- a/tools/calibrate_torch_thresholds.py +++ b/tools/calibrate_torch_thresholds.py @@ -569,6 +569,7 @@ def _full_backtest( rows: list[dict[str, Any]] = [] max_hold = max(12, horizon * 8) stop_loss_percent = max(0.003, min(0.08, float(settings.stop_loss_percent))) * 100.0 + stop_loss_exit_enabled = bool(getattr(settings, "stop_loss_exit_enabled", True)) atr_multiplier = max(0.5, min(10.0, float(settings.atr_trailing_multiplier))) for record in sorted(records, key=lambda item: (item.timestamp, item.symbol)): position = positions.get(record.symbol) @@ -576,10 +577,15 @@ def _full_backtest( position["highest"] = max(position["highest"], record.close) net_percent = _net_percent(position["entry_price"], record.close, round_trip_cost) held = record.index - int(position["entry_index"]) - atr_stop = ( - record.close <= position["highest"] - record.atr * atr_multiplier + atr_stop_level = ( + position["highest"] - record.atr * atr_multiplier if record.atr > 0 and position["highest"] > position["entry_price"] - else False + else None + ) + atr_stop = bool( + atr_stop_level is not None + and record.close <= atr_stop_level + and (stop_loss_exit_enabled or atr_stop_level > position["entry_price"]) ) weak_forecast = ( record.expected_percent < thresholds.edge @@ -587,7 +593,7 @@ def _full_backtest( or record.skill <= 0.0 ) exit_reason = "" - if net_percent <= -stop_loss_percent: + if stop_loss_exit_enabled and net_percent <= -stop_loss_percent: exit_reason = "stop_loss" elif atr_stop: exit_reason = "atr_trailing_stop" @@ -663,6 +669,7 @@ def _benchmark_backtest( rows: list[dict[str, Any]] = [] max_hold = max(12, horizon * 8) stop_loss_percent = max(0.003, min(0.08, float(settings.stop_loss_percent))) * 100.0 + stop_loss_exit_enabled = bool(getattr(settings, "stop_loss_exit_enabled", True)) atr_multiplier = max(0.5, min(10.0, float(settings.atr_trailing_multiplier))) for record in sorted(records, key=lambda item: (item.timestamp, item.symbol)): position = positions.get(record.symbol) @@ -670,13 +677,18 @@ def _benchmark_backtest( position["highest"] = max(position["highest"], record.close) net_percent = _net_percent(position["entry_price"], record.close, round_trip_cost) held = record.index - int(position["entry_index"]) - atr_stop = ( - record.close <= position["highest"] - record.atr * atr_multiplier + atr_stop_level = ( + position["highest"] - record.atr * atr_multiplier if record.atr > 0 and position["highest"] > position["entry_price"] - else False + else None + ) + atr_stop = bool( + atr_stop_level is not None + and record.close <= atr_stop_level + and (stop_loss_exit_enabled or atr_stop_level > position["entry_price"]) ) exit_reason = "" - if net_percent <= -stop_loss_percent: + if stop_loss_exit_enabled and net_percent <= -stop_loss_percent: exit_reason = "stop_loss" elif atr_stop: exit_reason = "atr_trailing_stop"