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
|
TIME_SERIES_MODEL_MAX_AGE_HOURS=48
|
||||||
MARKET_TICKER_MAX_AGE_SECONDS=45
|
MARKET_TICKER_MAX_AGE_SECONDS=45
|
||||||
STOP_LOSS_PERCENT=0.04
|
STOP_LOSS_PERCENT=0.04
|
||||||
|
STOP_LOSS_EXIT_ENABLED=false
|
||||||
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
|
||||||
|
# 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
|
ENTRY_COOLDOWN_SECONDS=180
|
||||||
MAX_DAILY_DRAWDOWN_USDT=6
|
MAX_DAILY_DRAWDOWN_USDT=6
|
||||||
MIN_CASH_RESERVE_USDT=5
|
MIN_CASH_RESERVE_USDT=5
|
||||||
|
|||||||
@@ -203,9 +203,12 @@ TIME_SERIES_REQUIRE_FRESH_MODEL=true
|
|||||||
TIME_SERIES_MODEL_MAX_AGE_HOURS=48
|
TIME_SERIES_MODEL_MAX_AGE_HOURS=48
|
||||||
MARKET_TICKER_MAX_AGE_SECONDS=45
|
MARKET_TICKER_MAX_AGE_SECONDS=45
|
||||||
STOP_LOSS_PERCENT=0.04
|
STOP_LOSS_PERCENT=0.04
|
||||||
|
STOP_LOSS_EXIT_ENABLED=false
|
||||||
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
|
||||||
|
PROFIT_ONLY_EXIT_ENABLED=true
|
||||||
|
MIN_EXIT_NET_PERCENT=0.31
|
||||||
ENTRY_COOLDOWN_SECONDS=180
|
ENTRY_COOLDOWN_SECONDS=180
|
||||||
MAX_DAILY_DRAWDOWN_USDT=6
|
MAX_DAILY_DRAWDOWN_USDT=6
|
||||||
TAKER_FEE_RATE=0.001
|
TAKER_FEE_RATE=0.001
|
||||||
@@ -218,6 +221,12 @@ SLIPPAGE_RATE=0.0003
|
|||||||
|
|
||||||
Для быстрого режима рекомендуется оставлять `WEBSOCKET_ENABLED=true`: WebSocket дает частые рыночные обновления, а REST используется как периодическая сверка. Я не могу подтвердить, что быстрый режим повысит прибыльность; он только уменьшает техническую задержку реакции стратегии.
|
Для быстрого режима рекомендуется оставлять `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-режим
|
||||||
|
|
||||||
Live-режим специально заблокирован. Для включения нужны все значения:
|
Live-режим специально заблокирован. Для включения нужны все значения:
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""Crypto spot trading bot package."""
|
"""Crypto spot trading bot package."""
|
||||||
|
|
||||||
__version__ = "1.1.1"
|
__version__ = "1.1.2"
|
||||||
|
|||||||
+28
-8
@@ -13,7 +13,11 @@ from crypto_spot_bot.learning import TradeLearner
|
|||||||
from crypto_spot_bot.market_data import MarketData
|
from crypto_spot_bot.market_data import MarketData
|
||||||
from crypto_spot_bot.models import BotStatus, Signal, Ticker, utc_now
|
from crypto_spot_bot.models import BotStatus, Signal, Ticker, utc_now
|
||||||
from crypto_spot_bot.patterns import PatternAnalyzer
|
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.storage import Storage
|
||||||
from crypto_spot_bot.time_series import TimeSeriesForecaster, _barrier_outcome
|
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
|
adaptive_rules["reduce_now"] = position.id is not None and position.id == reduction_candidate_id
|
||||||
learning = {"adaptive_rules": adaptive_rules}
|
learning = {"adaptive_rules": adaptive_rules}
|
||||||
signal = self.strategy.exit_signal(position, candles, ticker, learning, forecast)
|
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)
|
self._record_signal(signal)
|
||||||
if signal.action == "SELL" and ticker is not None:
|
if signal.action == "SELL" and ticker is not None:
|
||||||
await asyncio.to_thread(self.broker.sell, position, ticker, signal.reason)
|
await asyncio.to_thread(self.broker.sell, position, ticker, signal.reason)
|
||||||
@@ -371,14 +377,28 @@ class CryptoSpotBot:
|
|||||||
volume_24h=0.0,
|
volume_24h=0.0,
|
||||||
change_24h=0.0,
|
change_24h=0.0,
|
||||||
)
|
)
|
||||||
self.broker.sell(
|
candidate = Signal(
|
||||||
position,
|
position.symbol,
|
||||||
synthetic_ticker,
|
"SELL",
|
||||||
f"{self.settings.strategy_mode}: закрыта старая paper-позиция вне списка разрешенных пар",
|
0.5,
|
||||||
)
|
f"{self.settings.strategy_mode}: старая paper-позиция вне списка разрешенных пар",
|
||||||
self.storage.event(
|
{
|
||||||
f"{position.symbol}: старая paper-позиция закрыта при переходе на {self.settings.strategy_mode}"
|
"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:
|
def _reduction_candidate_id(self, prices: dict[str, float]) -> int | None:
|
||||||
rules = self._with_exposure_context(self.learner.state.adaptive_rules or {})
|
rules = self._with_exposure_context(self.learner.state.adaptive_rules or {})
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ class Settings:
|
|||||||
database_path: Path
|
database_path: Path
|
||||||
log_path: Path
|
log_path: Path
|
||||||
env_file_path: Path
|
env_file_path: Path
|
||||||
|
profit_only_exit_enabled: bool = True
|
||||||
api_auth_token: str = ""
|
api_auth_token: str = ""
|
||||||
training_worker_token: str = ""
|
training_worker_token: str = ""
|
||||||
trusted_proxy_user_header: 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")),
|
database_path=Path(os.getenv("DATABASE_PATH", "runtime/tradebot.sqlite3")),
|
||||||
log_path=Path(os.getenv("LOG_PATH", "runtime/tradebot.log")),
|
log_path=Path(os.getenv("LOG_PATH", "runtime/tradebot.log")),
|
||||||
env_file_path=env_path,
|
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(),
|
api_auth_token=os.getenv("TRADEBOT_API_TOKEN", "").strip(),
|
||||||
training_worker_token=os.getenv("TRADEBOT_TRAINING_TOKEN", "").strip(),
|
training_worker_token=os.getenv("TRADEBOT_TRAINING_TOKEN", "").strip(),
|
||||||
trusted_proxy_user_header=os.getenv("TRUSTED_PROXY_USER_HEADER", "").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")
|
errors.append("position count limits must be positive")
|
||||||
if settings.taker_fee_rate < 0 or settings.slippage_rate < 0:
|
if settings.taker_fee_rate < 0 or settings.slippage_rate < 0:
|
||||||
errors.append("TAKER_FEE_RATE and SLIPPAGE_RATE must be non-negative")
|
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:
|
if settings.market_ticker_max_age_seconds <= 0:
|
||||||
errors.append("MARKET_TICKER_MAX_AGE_SECONDS must be positive")
|
errors.append("MARKET_TICKER_MAX_AGE_SECONDS must be positive")
|
||||||
if settings.time_series_model_max_age_hours <= 0:
|
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,
|
"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,
|
"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,
|
"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,
|
||||||
|
|||||||
@@ -387,6 +387,8 @@ class SpotStrategy:
|
|||||||
"adaptive_rules": adaptive,
|
"adaptive_rules": adaptive,
|
||||||
}
|
}
|
||||||
if self.settings.stop_loss_exit_enabled and price <= position.stop_loss:
|
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)
|
return Signal(position.symbol, "SELL", 1.0, "сработал стоп-лосс", diagnostics)
|
||||||
if price >= position.take_profit:
|
if price >= position.take_profit:
|
||||||
return Signal(position.symbol, "SELL", 0.96, "сработал тейк-профит", diagnostics)
|
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),
|
"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:
|
||||||
|
diagnostics["emergency_exit"] = True
|
||||||
|
diagnostics["emergency_exit_type"] = "configured_stop_loss"
|
||||||
return Signal(position.symbol, "SELL", 1.0, "сработал стоп-лосс", diagnostics)
|
return Signal(position.symbol, "SELL", 1.0, "сработал стоп-лосс", diagnostics)
|
||||||
if price >= effective_take_profit:
|
if price >= effective_take_profit:
|
||||||
return Signal(position.symbol, "SELL", 0.96, "сработал тейк-профит", diagnostics)
|
return Signal(position.symbol, "SELL", 0.96, "сработал тейк-профит", diagnostics)
|
||||||
@@ -718,6 +722,8 @@ def _trend_macd_exit_signal(
|
|||||||
"close_below_ema50": close_below_ema50,
|
"close_below_ema50": close_below_ema50,
|
||||||
}
|
}
|
||||||
if effective_stop_loss is not None and price <= effective_stop_loss:
|
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)
|
return Signal(position.symbol, "SELL", 1.0, "trend_macd: сработал стоп-лосс", 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:
|
||||||
return Signal(position.symbol, "SELL", 0.94, "trend_macd: сработал ATR trailing stop", diagnostics)
|
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["hold_seconds"] = hold_seconds
|
||||||
diagnostics["min_hold_seconds"] = settings.min_hold_seconds
|
diagnostics["min_hold_seconds"] = settings.min_hold_seconds
|
||||||
if effective_stop_loss is not None and price <= effective_stop_loss:
|
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)
|
return Signal(position.symbol, "SELL", 1.0, "torch_forecast: stop-loss hit", diagnostics)
|
||||||
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)
|
||||||
@@ -1827,6 +1835,96 @@ 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 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:
|
def _min_exit_net_percent(settings: Settings) -> float:
|
||||||
return round(_clamp(settings.min_exit_net_percent, 0.0, 5.0), 4)
|
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 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.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]:
|
def _ready_candles() -> list[Candle]:
|
||||||
|
|||||||
Reference in New Issue
Block a user