From 991b77351c7a7f29e2f9b667cdfd80dda36d3952 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D1=83=D1=80=D0=BD=D0=B0=D1=82=20=D0=90=D0=BD=D0=B4?= =?UTF-8?q?=D1=80=D0=B5=D0=B9?= Date: Wed, 15 Jul 2026 20:23:31 +0300 Subject: [PATCH] feat: enforce profit-only spot exits --- .env.example | 5 ++ README.md | 9 ++++ crypto_spot_bot/__init__.py | 2 +- crypto_spot_bot/bot.py | 36 ++++++++++--- crypto_spot_bot/config.py | 4 ++ crypto_spot_bot/dashboard.py | 1 + crypto_spot_bot/strategy.py | 98 ++++++++++++++++++++++++++++++++++++ tests/test_strategy.py | 80 ++++++++++++++++++++++++++++- 8 files changed, 224 insertions(+), 11 deletions(-) diff --git a/.env.example b/.env.example index fff7231..eadc13d 100644 --- a/.env.example +++ b/.env.example @@ -93,9 +93,14 @@ TIME_SERIES_REQUIRE_FRESH_MODEL=true TIME_SERIES_MODEL_MAX_AGE_HOURS=48 MARKET_TICKER_MAX_AGE_SECONDS=45 STOP_LOSS_PERCENT=0.04 +STOP_LOSS_EXIT_ENABLED=false TAKE_PROFIT_PERCENT=0.035 TRAILING_STOP_PERCENT=0.015 MIN_HOLD_SECONDS=180 +# Ordinary RSI/EMA/model/exposure exits are only executed when the estimated +# result after entry fee, exit fee, spread and slippage clears this net margin. +PROFIT_ONLY_EXIT_ENABLED=true +MIN_EXIT_NET_PERCENT=0.31 ENTRY_COOLDOWN_SECONDS=180 MAX_DAILY_DRAWDOWN_USDT=6 MIN_CASH_RESERVE_USDT=5 diff --git a/README.md b/README.md index c552214..659c16a 100644 --- a/README.md +++ b/README.md @@ -203,9 +203,12 @@ TIME_SERIES_REQUIRE_FRESH_MODEL=true TIME_SERIES_MODEL_MAX_AGE_HOURS=48 MARKET_TICKER_MAX_AGE_SECONDS=45 STOP_LOSS_PERCENT=0.04 +STOP_LOSS_EXIT_ENABLED=false TAKE_PROFIT_PERCENT=0.035 TRAILING_STOP_PERCENT=0.015 MIN_HOLD_SECONDS=180 +PROFIT_ONLY_EXIT_ENABLED=true +MIN_EXIT_NET_PERCENT=0.31 ENTRY_COOLDOWN_SECONDS=180 MAX_DAILY_DRAWDOWN_USDT=6 TAKER_FEE_RATE=0.001 @@ -218,6 +221,12 @@ SLIPPAGE_RATE=0.0003 Для быстрого режима рекомендуется оставлять `WEBSOCKET_ENABLED=true`: WebSocket дает частые рыночные обновления, а REST используется как периодическая сверка. Я не могу подтвердить, что быстрый режим повысит прибыльность; он только уменьшает техническую задержку реакции стратегии. +## Profit-only выходы + +При `PROFIT_ONLY_EXIT_ENABLED=true` единый gate перед исполнением блокирует любой обычный `SELL`, если ожидаемый чистый результат с учетом входной и выходной комиссии, bid и проскальзывания ниже `MIN_EXIT_NET_PERCENT`. Это распространяется на RSI, EMA/MACD, ослабление прогноза, trailing и адаптивное снижение экспозиции. Явно помеченные аварийные выходы не блокируются; к ним относятся включенный оператором stop-loss, отказ установки защитного ордера в live и удаление старой paper-пары из торговой вселенной. + +Количество зависших позиций ограничивается `MAX_OPEN_POSITIONS`, `MAX_POSITIONS_PER_SYMBOL`, `MAX_SYMBOL_EXPOSURE_USDT` и `MAX_TOTAL_EXPOSURE_USDT`. Когда лимит достигнут, новые покупки блокируются, но существующие позиции продолжают отслеживаться. + ## Live-режим Live-режим специально заблокирован. Для включения нужны все значения: diff --git a/crypto_spot_bot/__init__.py b/crypto_spot_bot/__init__.py index 1fb70e9..ea81532 100644 --- a/crypto_spot_bot/__init__.py +++ b/crypto_spot_bot/__init__.py @@ -1,3 +1,3 @@ """Crypto spot trading bot package.""" -__version__ = "1.1.1" +__version__ = "1.1.2" diff --git a/crypto_spot_bot/bot.py b/crypto_spot_bot/bot.py index f6ddd0b..d7d09b8 100644 --- a/crypto_spot_bot/bot.py +++ b/crypto_spot_bot/bot.py @@ -13,7 +13,11 @@ from crypto_spot_bot.learning import TradeLearner from crypto_spot_bot.market_data import MarketData from crypto_spot_bot.models import BotStatus, Signal, Ticker, utc_now from crypto_spot_bot.patterns import PatternAnalyzer -from crypto_spot_bot.strategy import SpotStrategy, torch_model_readiness_reasons +from crypto_spot_bot.strategy import ( + SpotStrategy, + apply_profit_only_exit_policy, + torch_model_readiness_reasons, +) from crypto_spot_bot.storage import Storage from crypto_spot_bot.time_series import TimeSeriesForecaster, _barrier_outcome @@ -165,6 +169,8 @@ class CryptoSpotBot: adaptive_rules["reduce_now"] = position.id is not None and position.id == reduction_candidate_id learning = {"adaptive_rules": adaptive_rules} signal = self.strategy.exit_signal(position, candles, ticker, learning, forecast) + if ticker is not None: + signal = apply_profit_only_exit_policy(self.settings, position, ticker, signal) self._record_signal(signal) if signal.action == "SELL" and ticker is not None: await asyncio.to_thread(self.broker.sell, position, ticker, signal.reason) @@ -371,14 +377,28 @@ class CryptoSpotBot: volume_24h=0.0, change_24h=0.0, ) - self.broker.sell( - position, - synthetic_ticker, - f"{self.settings.strategy_mode}: закрыта старая paper-позиция вне списка разрешенных пар", - ) - self.storage.event( - f"{position.symbol}: старая paper-позиция закрыта при переходе на {self.settings.strategy_mode}" + candidate = Signal( + position.symbol, + "SELL", + 0.5, + f"{self.settings.strategy_mode}: старая paper-позиция вне списка разрешенных пар", + { + "emergency_exit": True, + "emergency_exit_type": "symbol_removed_from_universe", + }, ) + decision = apply_profit_only_exit_policy(self.settings, position, synthetic_ticker, candidate) + self._record_signal(decision) + if decision.action == "SELL": + self.broker.sell(position, synthetic_ticker, decision.reason) + self.storage.event( + f"{position.symbol}: старая paper-позиция закрыта при переходе на {self.settings.strategy_mode}" + ) + else: + self.storage.event( + f"{position.symbol}: старая paper-позиция сохранена политикой profit-only", + "WARN", + ) def _reduction_candidate_id(self, prices: dict[str, float]) -> int | None: rules = self._with_exposure_context(self.learner.state.adaptive_rules or {}) diff --git a/crypto_spot_bot/config.py b/crypto_spot_bot/config.py index 26a66fc..a5b109f 100644 --- a/crypto_spot_bot/config.py +++ b/crypto_spot_bot/config.py @@ -155,6 +155,7 @@ class Settings: database_path: Path log_path: Path env_file_path: Path + profit_only_exit_enabled: bool = True api_auth_token: str = "" training_worker_token: str = "" trusted_proxy_user_header: str = "" @@ -332,6 +333,7 @@ def load_settings(env_file: str | Path | None = None) -> Settings: database_path=Path(os.getenv("DATABASE_PATH", "runtime/tradebot.sqlite3")), log_path=Path(os.getenv("LOG_PATH", "runtime/tradebot.log")), env_file_path=env_path, + profit_only_exit_enabled=_bool_env("PROFIT_ONLY_EXIT_ENABLED", True), api_auth_token=os.getenv("TRADEBOT_API_TOKEN", "").strip(), training_worker_token=os.getenv("TRADEBOT_TRAINING_TOKEN", "").strip(), trusted_proxy_user_header=os.getenv("TRUSTED_PROXY_USER_HEADER", "").strip(), @@ -392,6 +394,8 @@ def _validate_settings(settings: Settings) -> None: errors.append("position count limits must be positive") if settings.taker_fee_rate < 0 or settings.slippage_rate < 0: errors.append("TAKER_FEE_RATE and SLIPPAGE_RATE must be non-negative") + if not 0 <= settings.min_exit_net_percent <= 5: + errors.append("MIN_EXIT_NET_PERCENT must be in range 0..5") if settings.market_ticker_max_age_seconds <= 0: errors.append("MARKET_TICKER_MAX_AGE_SECONDS must be positive") if settings.time_series_model_max_age_hours <= 0: diff --git a/crypto_spot_bot/dashboard.py b/crypto_spot_bot/dashboard.py index fd351ce..3d18f29 100644 --- a/crypto_spot_bot/dashboard.py +++ b/crypto_spot_bot/dashboard.py @@ -498,6 +498,7 @@ def _safe_config(settings: Settings) -> dict[str, Any]: "trailing_stop_percent": settings.trailing_stop_percent, "min_hold_seconds": settings.min_hold_seconds, "min_exit_net_percent": settings.min_exit_net_percent, + "profit_only_exit_enabled": settings.profit_only_exit_enabled, "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 2316321..dfb6509 100644 --- a/crypto_spot_bot/strategy.py +++ b/crypto_spot_bot/strategy.py @@ -387,6 +387,8 @@ class SpotStrategy: "adaptive_rules": adaptive, } if self.settings.stop_loss_exit_enabled and price <= position.stop_loss: + diagnostics["emergency_exit"] = True + diagnostics["emergency_exit_type"] = "configured_stop_loss" return Signal(position.symbol, "SELL", 1.0, "сработал стоп-лосс", diagnostics) if price >= position.take_profit: return Signal(position.symbol, "SELL", 0.96, "сработал тейк-профит", diagnostics) @@ -517,6 +519,8 @@ class SpotStrategy: "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: + diagnostics["emergency_exit"] = True + diagnostics["emergency_exit_type"] = "configured_stop_loss" return Signal(position.symbol, "SELL", 1.0, "сработал стоп-лосс", diagnostics) if price >= effective_take_profit: return Signal(position.symbol, "SELL", 0.96, "сработал тейк-профит", diagnostics) @@ -718,6 +722,8 @@ def _trend_macd_exit_signal( "close_below_ema50": close_below_ema50, } if effective_stop_loss is not None and price <= effective_stop_loss: + diagnostics["emergency_exit"] = True + diagnostics["emergency_exit_type"] = "configured_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) @@ -1048,6 +1054,8 @@ def _torch_forecast_exit_signal( 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: + diagnostics["emergency_exit"] = True + diagnostics["emergency_exit_type"] = "configured_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) @@ -1827,6 +1835,96 @@ def _estimated_exit_net_percent(position: Position, price: float, settings: Sett return gross_percent - round_trip_cost_percent +def apply_profit_only_exit_policy( + settings: Settings, + position: Position, + ticker: Ticker, + signal: Signal, +) -> Signal: + """Block every ordinary exit that would realize less than the configured net profit. + + The estimate mirrors the paper broker fill calculation. Live fills can still differ, + so the configured minimum also acts as a safety margin. A loss-making exit is only + allowed when the producing subsystem marks it explicitly as an emergency. + """ + if signal.action != "SELL" or not settings.profit_only_exit_enabled: + return signal + + diagnostics = dict(signal.diagnostics) + expected_fill_price = _expected_sell_fill_price(ticker, settings) + expected_net_usdt = _expected_exit_net_usdt(position, expected_fill_price, settings) + expected_net_percent = ( + expected_net_usdt / position.notional_usdt * 100 + if position.notional_usdt > 0 + else 0.0 + ) + adaptive = diagnostics.get("adaptive_rules") + adaptive_minimum = ( + _safe_float(adaptive.get("min_exit_profit_percent"), 0.0) + if isinstance(adaptive, dict) + else 0.0 + ) + signal_minimum = _safe_float(diagnostics.get("min_exit_profit_percent"), 0.0) + minimum_net_percent = max( + _min_exit_net_percent(settings), + adaptive_minimum, + signal_minimum, + ) + emergency = diagnostics.get("emergency_exit") is True + diagnostics.update( + { + "exit_policy": "profit_only", + "profit_only_exit_enabled": True, + "expected_exit_fill_price": round(expected_fill_price, 12), + "expected_exit_net_usdt": round(expected_net_usdt, 8), + "expected_exit_net_percent": round(expected_net_percent, 4), + "required_exit_net_percent": round(minimum_net_percent, 4), + "emergency_exit": emergency, + } + ) + if emergency or expected_net_percent + 1e-9 >= minimum_net_percent: + diagnostics["exit_policy_blocked"] = False + return Signal( + signal.symbol, + signal.action, + signal.confidence, + signal.reason, + diagnostics, + signal.created_at, + ) + + diagnostics.update( + { + "exit_policy_blocked": True, + "blocked_sell_reason": signal.reason, + "blocked_sell_confidence": signal.confidence, + } + ) + return Signal( + signal.symbol, + "HOLD", + min(signal.confidence, 0.49), + ( + "profit-only: продажа заблокирована, ожидаемая чистая доходность " + f"{expected_net_percent:.4f}% ниже минимума {minimum_net_percent:.4f}%" + ), + diagnostics, + signal.created_at, + ) + + +def _expected_sell_fill_price(ticker: Ticker, settings: Settings) -> float: + base = ticker.bid if ticker.bid > 0 else ticker.last_price + return base * (1 - settings.slippage_rate) + + +def _expected_exit_net_usdt(position: Position, fill_price: float, settings: Settings) -> float: + exit_notional = position.qty * fill_price + exit_fee = exit_notional * settings.taker_fee_rate + gross_pnl = (fill_price - position.entry_price) * position.qty + return gross_pnl - position.entry_fee_usdt - exit_fee + + def _min_exit_net_percent(settings: Settings) -> float: return round(_clamp(settings.min_exit_net_percent, 0.0, 5.0), 4) diff --git a/tests/test_strategy.py b/tests/test_strategy.py index e3e5934..5a4c236 100644 --- a/tests/test_strategy.py +++ b/tests/test_strategy.py @@ -2,9 +2,85 @@ from __future__ import annotations from datetime import timedelta -from crypto_spot_bot.models import Candle, Position, Ticker, utc_now +from crypto_spot_bot.models import Candle, Position, Signal, Ticker, utc_now from crypto_spot_bot.patterns import PatternAnalyzer -from crypto_spot_bot.strategy import SpotStrategy +from crypto_spot_bot.strategy import SpotStrategy, apply_profit_only_exit_policy + + +def test_profit_only_policy_blocks_every_ordinary_loss_exit(make_settings, tmp_path) -> None: + settings = make_settings( + tmp_path, + profit_only_exit_enabled=True, + min_exit_net_percent=0.31, + taker_fee_rate=0.001, + slippage_rate=0.0003, + ) + position = Position(1, "ETHUSDT", 1, 100, 100, 0.1, 96, 103.5, 100) + ticker = Ticker("ETHUSDT", 100.2, 100.19, 100.21, 1_000_000, 100, 0) + candidate = Signal("ETHUSDT", "SELL", 0.76, "RSI high and price turned down") + + decision = apply_profit_only_exit_policy(settings, position, ticker, candidate) + + assert decision.action == "HOLD" + assert decision.diagnostics["exit_policy_blocked"] is True + assert decision.diagnostics["blocked_sell_reason"] == candidate.reason + assert decision.diagnostics["expected_exit_net_percent"] < settings.min_exit_net_percent + + +def test_profit_only_policy_allows_exit_above_net_margin(make_settings, tmp_path) -> None: + settings = make_settings( + tmp_path, + profit_only_exit_enabled=True, + min_exit_net_percent=0.31, + taker_fee_rate=0.001, + slippage_rate=0.0003, + ) + position = Position(1, "ETHUSDT", 1, 100, 100, 0.1, 96, 103.5, 101) + ticker = Ticker("ETHUSDT", 101, 100.99, 101.01, 1_000_000, 100, 0) + candidate = Signal("ETHUSDT", "SELL", 0.96, "take-profit") + + decision = apply_profit_only_exit_policy(settings, position, ticker, candidate) + + assert decision.action == "SELL" + assert decision.diagnostics["exit_policy_blocked"] is False + assert decision.diagnostics["expected_exit_net_percent"] >= settings.min_exit_net_percent + + +def test_profit_only_policy_uses_adaptive_minimum(make_settings, tmp_path) -> None: + settings = make_settings(tmp_path, profit_only_exit_enabled=True, min_exit_net_percent=0.20) + position = Position(1, "ETHUSDT", 1, 100, 100, 0.1, 96, 103.5, 101) + ticker = Ticker("ETHUSDT", 101, 100.99, 101.01, 1_000_000, 100, 0) + candidate = Signal( + "ETHUSDT", + "SELL", + 0.76, + "EMA exit", + {"adaptive_rules": {"min_exit_profit_percent": 0.80}}, + ) + + decision = apply_profit_only_exit_policy(settings, position, ticker, candidate) + + assert decision.action == "HOLD" + assert decision.diagnostics["required_exit_net_percent"] == 0.80 + + +def test_profit_only_policy_allows_explicit_emergency_loss_exit(make_settings, tmp_path) -> None: + settings = make_settings(tmp_path, profit_only_exit_enabled=True, min_exit_net_percent=0.31) + position = Position(1, "ETHUSDT", 1, 100, 100, 0.1, 96, 103.5, 100) + ticker = Ticker("ETHUSDT", 95, 94.99, 95.01, 1_000_000, 100, 0) + candidate = Signal( + "ETHUSDT", + "SELL", + 1.0, + "configured emergency", + {"emergency_exit": True, "emergency_exit_type": "configured_stop_loss"}, + ) + + decision = apply_profit_only_exit_policy(settings, position, ticker, candidate) + + assert decision.action == "SELL" + assert decision.diagnostics["exit_policy_blocked"] is False + assert decision.diagnostics["expected_exit_net_percent"] < 0 def _ready_candles() -> list[Candle]: