Require minimum net profit for forecast exits

This commit is contained in:
Codex
2026-07-02 15:35:29 +03:00
parent f0c70e2e1f
commit 33c3831bf2
5 changed files with 103 additions and 7 deletions
+2
View File
@@ -142,6 +142,7 @@ class Settings:
take_profit_percent: float take_profit_percent: float
trailing_stop_percent: float trailing_stop_percent: float
min_hold_seconds: int min_hold_seconds: int
min_exit_net_percent: float
entry_cooldown_seconds: int entry_cooldown_seconds: int
max_daily_drawdown_usdt: float max_daily_drawdown_usdt: float
min_cash_reserve_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), take_profit_percent=_float_env("TAKE_PROFIT_PERCENT", 0.035),
trailing_stop_percent=_float_env("TRAILING_STOP_PERCENT", 0.015), trailing_stop_percent=_float_env("TRAILING_STOP_PERCENT", 0.015),
min_hold_seconds=_int_env("MIN_HOLD_SECONDS", 180), 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), entry_cooldown_seconds=_int_env("ENTRY_COOLDOWN_SECONDS", 180),
max_daily_drawdown_usdt=_float_env("MAX_DAILY_DRAWDOWN_USDT", 6.0), max_daily_drawdown_usdt=_float_env("MAX_DAILY_DRAWDOWN_USDT", 6.0),
min_cash_reserve_usdt=_float_env("MIN_CASH_RESERVE_USDT", 5.0), min_cash_reserve_usdt=_float_env("MIN_CASH_RESERVE_USDT", 5.0),
+1
View File
@@ -323,6 +323,7 @@ def _safe_config(settings: Settings) -> dict[str, Any]:
"take_profit_percent": settings.take_profit_percent, "take_profit_percent": settings.take_profit_percent,
"trailing_stop_percent": settings.trailing_stop_percent, "trailing_stop_percent": settings.trailing_stop_percent,
"min_hold_seconds": settings.min_hold_seconds, "min_hold_seconds": settings.min_hold_seconds,
"min_exit_net_percent": settings.min_exit_net_percent,
"entry_cooldown_seconds": settings.entry_cooldown_seconds, "entry_cooldown_seconds": settings.entry_cooldown_seconds,
"max_daily_drawdown_usdt": settings.max_daily_drawdown_usdt, "max_daily_drawdown_usdt": settings.max_daily_drawdown_usdt,
"min_cash_reserve_usdt": settings.min_cash_reserve_usdt, "min_cash_reserve_usdt": settings.min_cash_reserve_usdt,
+24 -7
View File
@@ -394,6 +394,7 @@ class SpotStrategy:
effective_take_profit = position.entry_price * (1 + take_profit_percent) effective_take_profit = position.entry_price * (1 + take_profit_percent)
trailing = position.trailing_stop(trailing_percent) trailing = position.trailing_stop(trailing_percent)
estimated_exit_net_percent = _estimated_exit_net_percent(position, price, self.settings) estimated_exit_net_percent = _estimated_exit_net_percent(position, price, self.settings)
min_exit_net_percent = _min_exit_net_percent(self.settings)
diagnostics = { diagnostics = {
"price": price, "price": price,
"entry_price": position.entry_price, "entry_price": position.entry_price,
@@ -408,6 +409,7 @@ class SpotStrategy:
"adaptive_rules": adaptive, "adaptive_rules": adaptive,
"forecast": forecast, "forecast": forecast,
"estimated_exit_net_percent": round(estimated_exit_net_percent, 4), "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), "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: 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, estimated_exit_net_percent=estimated_exit_net_percent,
stop_loss_percent=stop_loss_percent, stop_loss_percent=stop_loss_percent,
min_edge_percent=self.settings.time_series_min_edge_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: if forecast_exit is not None:
action, confidence, reason = forecast_exit 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_edge = max(0.0, settings.time_series_min_edge_percent)
min_probability = _torch_min_probability(settings) min_probability = _torch_min_probability(settings)
estimated_exit_net_percent = _estimated_exit_net_percent(position, price, 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_path = str(position.entry_diagnostics.get("entry_path", ""))
entry_edge_mode = str(position.entry_diagnostics.get("edge_mode", "")) entry_edge_mode = str(position.entry_diagnostics.get("edge_mode", ""))
rebound_fallback_position = entry_path == "rebound_fallback" or entry_edge_mode == "rebound_fallback" 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, "min_probability_up": min_probability,
"skill": skill, "skill": skill,
"estimated_exit_net_percent": round(estimated_exit_net_percent, 4), "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, "atr_14": latest.atr_14 if latest else None,
} }
hold_seconds = (utc_now() - position.opened_at).total_seconds() hold_seconds = (utc_now() - position.opened_at).total_seconds()
@@ -909,13 +914,15 @@ def _torch_forecast_exit_signal(
if price >= position.take_profit: if price >= position.take_profit:
return Signal(position.symbol, "SELL", 0.96, "torch_forecast: take-profit hit", diagnostics) 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 atr_trailing_stop is not None and price <= atr_trailing_stop:
if estimated_exit_net_percent < 0: if estimated_exit_net_percent < min_exit_net_percent:
diagnostics["atr_exit_blocked_by_cost"] = True diagnostics["atr_exit_blocked_by_min_profit"] = True
if estimated_exit_net_percent < 0:
diagnostics["atr_exit_blocked_by_cost"] = True
return Signal( return Signal(
position.symbol, position.symbol,
"HOLD", "HOLD",
0.45, 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, diagnostics,
) )
return Signal(position.symbol, "SELL", 0.94, "torch_forecast: ATR trailing stop hit", 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, estimated_exit_net_percent=estimated_exit_net_percent,
stop_loss_percent=stop_loss_percent, stop_loss_percent=stop_loss_percent,
min_edge_percent=min_edge, min_edge_percent=min_edge,
min_exit_net_percent=min_exit_net_percent,
) )
if forecast_exit is not None: if forecast_exit is not None:
action, confidence, reason = forecast_exit action, confidence, reason = forecast_exit
return Signal(position.symbol, action, confidence, reason, diagnostics) 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( return Signal(
position.symbol, position.symbol,
"HOLD", "HOLD",
0.44, 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}%" f"p_up={probability_up:.3f}, expected={expected_return:.4f}%"
), ),
diagnostics, diagnostics,
) )
weak_hold = expected_return < min_edge or probability_up < min_probability or skill <= 0.0 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( return Signal(
position.symbol, position.symbol,
"SELL", "SELL",
@@ -983,6 +993,8 @@ def _torch_forecast_exit_signal(
), ),
diagnostics, 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) 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 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: def _adaptive_indicator_exit_allowed(adaptive: dict, mode_key: str, estimated_exit_net_percent: float) -> bool:
mode = str(adaptive.get(mode_key, "normal")).lower() mode = str(adaptive.get(mode_key, "normal")).lower()
if mode != "profit_only": if mode != "profit_only":
@@ -1649,6 +1665,7 @@ def _forecast_exit_signal(
estimated_exit_net_percent: float, estimated_exit_net_percent: float,
stop_loss_percent: float, stop_loss_percent: float,
min_edge_percent: float, min_edge_percent: float,
min_exit_net_percent: float,
) -> tuple[str, float, str] | None: ) -> tuple[str, float, str] | None:
if not forecast.get("usable"): if not forecast.get("usable"):
return None return None
@@ -1660,7 +1677,7 @@ def _forecast_exit_signal(
if not strong_negative: if not strong_negative:
return None return None
reason = forecast.get("reason") or "ожидается снижение" 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}; фиксируем результат" return "SELL", 0.82, f"прогноз временного ряда ухудшился: {reason}; фиксируем результат"
loss_from_entry = ((price - position.entry_price) / position.entry_price) if position.entry_price else 0.0 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) soft_loss_limit = -max(0.003, stop_loss_percent * 0.35)
+1
View File
@@ -94,6 +94,7 @@ def make_settings():
take_profit_percent=0.035, take_profit_percent=0.035,
trailing_stop_percent=0.015, trailing_stop_percent=0.015,
min_hold_seconds=180, min_hold_seconds=180,
min_exit_net_percent=0.20,
entry_cooldown_seconds=180, entry_cooldown_seconds=180,
max_daily_drawdown_usdt=6.0, max_daily_drawdown_usdt=6.0,
min_cash_reserve_usdt=5.0, min_cash_reserve_usdt=5.0,
+75
View File
@@ -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 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: 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) settings = make_settings(tmp_path, strategy_mode="torch_forecast", min_hold_seconds=180)
strategy = SpotStrategy(settings) strategy = SpotStrategy(settings)