feat: enforce profit-only spot exits

This commit is contained in:
Курнат Андрей
2026-07-15 20:23:31 +03:00
parent 5082be2e5a
commit 991b77351c
8 changed files with 224 additions and 11 deletions
+5
View File
@@ -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
+9
View File
@@ -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-режим специально заблокирован. Для включения нужны все значения:
+1 -1
View File
@@ -1,3 +1,3 @@
"""Crypto spot trading bot package."""
__version__ = "1.1.1"
__version__ = "1.1.2"
+28 -8
View File
@@ -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 {})
+4
View File
@@ -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:
+1
View File
@@ -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,
+98
View File
@@ -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)
+78 -2
View File
@@ -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]: