feat: enforce profit-only spot exits
This commit is contained in:
+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