Allow disabling stop loss exits

This commit is contained in:
Codex
2026-06-29 16:39:36 +03:00
parent d58e20aa4d
commit 4bc8a4c4a0
8 changed files with 151 additions and 22 deletions
+1
View File
@@ -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
+2
View File
@@ -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),
+1
View File
@@ -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,
+1
View File
@@ -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": {},
+41 -14
View File
@@ -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
+1
View File
@@ -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,
+84
View File
@@ -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,
+20 -8
View File
@@ -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"