feat: enforce profit-only spot exits
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,3 +1,3 @@
|
||||
"""Crypto spot trading bot package."""
|
||||
|
||||
__version__ = "1.1.1"
|
||||
__version__ = "1.1.2"
|
||||
|
||||
+25
-5
@@ -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-позиция вне списка разрешенных пар",
|
||||
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 {})
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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]:
|
||||
|
||||
Reference in New Issue
Block a user