508 lines
15 KiB
Python
508 lines
15 KiB
Python
from __future__ import annotations
|
|
|
|
from datetime import timedelta
|
|
|
|
from crypto_spot_bot.models import Candle, Position, Ticker, utc_now
|
|
from crypto_spot_bot.patterns import PatternAnalyzer
|
|
from crypto_spot_bot.strategy import SpotStrategy
|
|
|
|
|
|
def _ready_candles() -> list[Candle]:
|
|
candles = []
|
|
for index in range(205):
|
|
candle = Candle(
|
|
timestamp=index,
|
|
open=100,
|
|
high=103,
|
|
low=99,
|
|
close=101,
|
|
volume=100,
|
|
ema_20=100,
|
|
ema_50=99,
|
|
ema_200=98,
|
|
rsi_14=45,
|
|
atr_14=1.2,
|
|
volume_ma_20=90,
|
|
)
|
|
candles.append(candle)
|
|
return candles
|
|
|
|
|
|
def _rebound_candles() -> list[Candle]:
|
|
candles = []
|
|
tail = [98.0, 97.3, 96.6, 95.9, 95.2, 94.8, 94.55, 94.42, 94.38, 94.40, 94.45, 94.56]
|
|
for index in range(205):
|
|
close = 104 - index * 0.025
|
|
if index >= 193:
|
|
close = tail[index - 193]
|
|
rsi = 45
|
|
if index >= 193:
|
|
rsi = min(42, 29 + max(0, index - 198))
|
|
candles.append(
|
|
Candle(
|
|
timestamp=index,
|
|
open=close - 0.12,
|
|
high=close + 0.25,
|
|
low=close - 0.26,
|
|
close=close,
|
|
volume=120,
|
|
ema_20=close + 0.35,
|
|
ema_50=close + 0.75,
|
|
ema_200=close + 1.4,
|
|
rsi_14=rsi,
|
|
atr_14=0.45,
|
|
volume_ma_20=100,
|
|
)
|
|
)
|
|
candles[-1].open = candles[-1].close - 0.20
|
|
candles[-1].low = candles[-1].close - 0.26
|
|
return candles
|
|
|
|
|
|
def test_strategy_emits_buy_when_score_passes_threshold(make_settings, tmp_path) -> None:
|
|
settings = make_settings(tmp_path)
|
|
strategy = SpotStrategy(settings)
|
|
ticker = Ticker(
|
|
symbol="BTCUSDT",
|
|
last_price=101,
|
|
bid=100.99,
|
|
ask=101.01,
|
|
turnover_24h=10_000_000,
|
|
volume_24h=1000,
|
|
change_24h=1.0,
|
|
)
|
|
|
|
signal = strategy.entry_signal("BTCUSDT", _ready_candles(), ticker, open_positions_for_symbol=0)
|
|
|
|
assert signal.action == "BUY"
|
|
assert signal.confidence >= settings.min_signal_confidence
|
|
|
|
|
|
def test_strategy_blocks_negative_long_pattern(make_settings, tmp_path) -> None:
|
|
settings = make_settings(tmp_path)
|
|
strategy = SpotStrategy(settings)
|
|
ticker = Ticker(
|
|
symbol="BTCUSDT",
|
|
last_price=101,
|
|
bid=100.99,
|
|
ask=101.01,
|
|
turnover_24h=10_000_000,
|
|
volume_24h=1000,
|
|
change_24h=1.0,
|
|
)
|
|
|
|
signal = strategy.entry_signal(
|
|
"BTCUSDT",
|
|
_ready_candles(),
|
|
ticker,
|
|
open_positions_for_symbol=0,
|
|
pattern={"label": "нисходящий тренд", "score": 0.28},
|
|
)
|
|
|
|
assert signal.action == "HOLD"
|
|
assert signal.diagnostics["entry_blocked_by_pattern"] is True
|
|
|
|
|
|
def test_strategy_blocks_strong_negative_learning(make_settings, tmp_path) -> None:
|
|
settings = make_settings(tmp_path)
|
|
strategy = SpotStrategy(settings)
|
|
ticker = Ticker(
|
|
symbol="BTCUSDT",
|
|
last_price=101,
|
|
bid=100.99,
|
|
ask=101.01,
|
|
turnover_24h=10_000_000,
|
|
volume_24h=1000,
|
|
change_24h=1.0,
|
|
)
|
|
|
|
signal = strategy.entry_signal(
|
|
"BTCUSDT",
|
|
_ready_candles(),
|
|
ticker,
|
|
open_positions_for_symbol=0,
|
|
pattern={"label": "нейтрально", "score": 0.5},
|
|
learning={
|
|
"sample_size": 10,
|
|
"net_pnl": -1.0,
|
|
"win_rate": 0.1,
|
|
"confidence_adjustment": -0.12,
|
|
"reason": "test",
|
|
},
|
|
)
|
|
|
|
assert signal.action == "HOLD"
|
|
assert signal.diagnostics["entry_blocked_by_learning"] is True
|
|
|
|
|
|
def test_strategy_blocks_entry_when_llm_advisor_blocks(make_settings, tmp_path) -> None:
|
|
settings = make_settings(tmp_path, llm_advisor_enabled=True)
|
|
strategy = SpotStrategy(settings)
|
|
ticker = Ticker(
|
|
symbol="BTCUSDT",
|
|
last_price=101,
|
|
bid=100.99,
|
|
ask=101.01,
|
|
turnover_24h=10_000_000,
|
|
volume_24h=1000,
|
|
change_24h=1.0,
|
|
)
|
|
|
|
signal = strategy.entry_signal(
|
|
"BTCUSDT",
|
|
_ready_candles(),
|
|
ticker,
|
|
open_positions_for_symbol=0,
|
|
llm={
|
|
"confidence_adjustment": -0.03,
|
|
"block_entry": True,
|
|
"reason_ru": "риск падения",
|
|
},
|
|
)
|
|
|
|
assert signal.action == "HOLD"
|
|
assert signal.diagnostics["entry_blocked_by_llm"] is True
|
|
assert signal.diagnostics["llm_adjustment"] == -0.03
|
|
|
|
|
|
def test_strategy_activates_grid_and_sets_position_size(make_settings, tmp_path) -> None:
|
|
settings = make_settings(tmp_path)
|
|
strategy = SpotStrategy(settings)
|
|
ticker = Ticker(
|
|
symbol="BTCUSDT",
|
|
last_price=101,
|
|
bid=100.99,
|
|
ask=101.01,
|
|
turnover_24h=10_000_000,
|
|
volume_24h=1000,
|
|
change_24h=0.1,
|
|
)
|
|
|
|
signal = strategy.entry_signal(
|
|
"BTCUSDT",
|
|
_ready_candles(),
|
|
ticker,
|
|
open_positions_for_symbol=2,
|
|
pattern={
|
|
"label": "боковик",
|
|
"score": 0.48,
|
|
"tags": ["боковик"],
|
|
"metrics": {"high20": 105, "low20": 100, "ema_gap_percent": 0.1, "ret_20_percent": 0.2},
|
|
},
|
|
llm={"market_regime": "range", "grid_suitable": True, "risk_level": "medium"},
|
|
)
|
|
|
|
assert signal.action == "BUY"
|
|
assert signal.diagnostics["trade_mode"] == "GRID"
|
|
assert signal.diagnostics["grid"]["active"] is True
|
|
assert 1 <= signal.diagnostics["position_notional_usdt"] <= settings.grid_max_position_usdt
|
|
|
|
|
|
def test_strategy_uses_fractional_kelly_position_size(make_settings, tmp_path) -> None:
|
|
settings = make_settings(tmp_path, max_position_usdt=20, kelly_fraction=0.25, kelly_max_fraction=0.20)
|
|
strategy = SpotStrategy(settings)
|
|
ticker = Ticker(
|
|
symbol="BTCUSDT",
|
|
last_price=101,
|
|
bid=100.99,
|
|
ask=101.01,
|
|
turnover_24h=10_000_000,
|
|
volume_24h=1000,
|
|
change_24h=1.0,
|
|
)
|
|
|
|
signal = strategy.entry_signal(
|
|
"BTCUSDT",
|
|
_ready_candles(),
|
|
ticker,
|
|
open_positions_for_symbol=0,
|
|
forecast={"usable": True, "probability_up": 0.70, "volatility_percent": 0.2},
|
|
account={"equity": 200.0},
|
|
)
|
|
|
|
sizing = signal.diagnostics["position_sizing"]
|
|
assert signal.action == "BUY"
|
|
assert sizing["method"] == "fractional_kelly"
|
|
assert sizing["kelly_probability_source"] == "forecast"
|
|
assert sizing["kelly_bankroll_usdt"] == 200.0
|
|
assert sizing["kelly_effective_fraction"] > 0
|
|
assert signal.diagnostics["position_notional_usdt"] == settings.max_position_usdt
|
|
|
|
|
|
def test_strategy_scales_kelly_with_positive_learning_multiplier(make_settings, tmp_path) -> None:
|
|
settings = make_settings(tmp_path, max_position_usdt=50, kelly_fraction=0.25, kelly_max_fraction=0.20)
|
|
strategy = SpotStrategy(settings)
|
|
ticker = Ticker(
|
|
symbol="BTCUSDT",
|
|
last_price=101,
|
|
bid=100.99,
|
|
ask=101.01,
|
|
turnover_24h=10_000_000,
|
|
volume_24h=1000,
|
|
change_24h=1.0,
|
|
)
|
|
common = dict(
|
|
symbol="BTCUSDT",
|
|
candles=_ready_candles(),
|
|
ticker=ticker,
|
|
open_positions_for_symbol=0,
|
|
forecast={"usable": True, "probability_up": 0.62, "volatility_percent": 0.2},
|
|
account={"equity": 200.0},
|
|
)
|
|
|
|
neutral = strategy.entry_signal(**common)
|
|
scaled = strategy.entry_signal(
|
|
**common,
|
|
learning={"adaptive_rules": {"effective_position_size_multiplier": 1.5}},
|
|
)
|
|
|
|
assert neutral.action == "BUY"
|
|
assert scaled.action == "BUY"
|
|
assert scaled.diagnostics["position_sizing"]["risk_multiplier"] > neutral.diagnostics["position_sizing"]["risk_multiplier"]
|
|
assert scaled.diagnostics["position_notional_usdt"] > neutral.diagnostics["position_notional_usdt"]
|
|
assert scaled.diagnostics["position_notional_usdt"] <= settings.max_position_usdt
|
|
|
|
|
|
def test_strategy_buys_probabilistic_rebound_after_stabilized_drop(make_settings, tmp_path) -> None:
|
|
settings = make_settings(tmp_path, rebound_entry_confidence=0.58, rebound_min_probability=0.58)
|
|
strategy = SpotStrategy(settings)
|
|
candles = _rebound_candles()
|
|
ticker = Ticker(
|
|
symbol="BTCUSDT",
|
|
last_price=candles[-1].close,
|
|
bid=candles[-1].close * 0.9999,
|
|
ask=candles[-1].close * 1.0001,
|
|
turnover_24h=10_000_000,
|
|
volume_24h=1000,
|
|
change_24h=-2.0,
|
|
)
|
|
pattern = PatternAnalyzer().analyze(candles, ticker).as_dict()
|
|
|
|
signal = strategy.entry_signal(
|
|
"BTCUSDT",
|
|
candles,
|
|
ticker,
|
|
open_positions_for_symbol=0,
|
|
pattern={**pattern, "label": "нисходящий тренд", "score": 0.28},
|
|
)
|
|
|
|
assert signal.action == "BUY"
|
|
assert signal.diagnostics["trade_mode"] == "REBOUND"
|
|
assert signal.diagnostics["rebound"]["active"] is True
|
|
assert signal.diagnostics["entry_blocked_by_pattern"] is False
|
|
assert signal.diagnostics["position_notional_usdt"] <= settings.rebound_max_position_usdt
|
|
|
|
|
|
def test_strategy_rebound_does_not_override_llm_block(make_settings, tmp_path) -> None:
|
|
settings = make_settings(
|
|
tmp_path,
|
|
llm_advisor_enabled=True,
|
|
rebound_entry_confidence=0.58,
|
|
rebound_min_probability=0.58,
|
|
)
|
|
strategy = SpotStrategy(settings)
|
|
candles = _rebound_candles()
|
|
ticker = Ticker(
|
|
symbol="BTCUSDT",
|
|
last_price=candles[-1].close,
|
|
bid=candles[-1].close * 0.9999,
|
|
ask=candles[-1].close * 1.0001,
|
|
turnover_24h=10_000_000,
|
|
volume_24h=1000,
|
|
change_24h=-2.0,
|
|
)
|
|
|
|
signal = strategy.entry_signal(
|
|
"BTCUSDT",
|
|
candles,
|
|
ticker,
|
|
open_positions_for_symbol=0,
|
|
pattern={"label": "нисходящий тренд", "score": 0.28},
|
|
llm={"block_entry": True, "reason_ru": "риск продолжения падения"},
|
|
)
|
|
|
|
assert signal.action == "HOLD"
|
|
assert signal.diagnostics["entry_blocked_by_llm"] is True
|
|
|
|
|
|
def test_strategy_trailing_stop_only_exits_after_profit(make_settings, tmp_path) -> None:
|
|
settings = make_settings(tmp_path)
|
|
strategy = SpotStrategy(settings)
|
|
candles = _ready_candles()
|
|
from crypto_spot_bot.models import Position
|
|
|
|
position = Position(
|
|
id=1,
|
|
symbol="BTCUSDT",
|
|
qty=1,
|
|
entry_price=100,
|
|
notional_usdt=100,
|
|
entry_fee_usdt=0.1,
|
|
stop_loss=90,
|
|
take_profit=120,
|
|
highest_price=100.5,
|
|
)
|
|
ticker = Ticker("BTCUSDT", 99.6, 99.5, 99.7, 1_000_000, 100, 0)
|
|
|
|
signal = strategy.exit_signal(position, candles, ticker)
|
|
|
|
assert signal.reason != "сработал trailing stop выше цены входа"
|
|
|
|
|
|
def test_strategy_adaptive_learning_holds_unprofitable_ema_exit(make_settings, tmp_path) -> None:
|
|
settings = make_settings(tmp_path, min_hold_seconds=60)
|
|
strategy = SpotStrategy(settings)
|
|
candles = _ready_candles()
|
|
candles[-2].close = 100.2
|
|
candles[-1].close = 99.9
|
|
candles[-1].ema_20 = 98.0
|
|
candles[-1].ema_50 = 100.0
|
|
position = Position(
|
|
id=1,
|
|
symbol="BTCUSDT",
|
|
qty=1,
|
|
entry_price=100,
|
|
notional_usdt=100,
|
|
entry_fee_usdt=0.1,
|
|
stop_loss=90,
|
|
take_profit=120,
|
|
highest_price=100.5,
|
|
opened_at=utc_now() - timedelta(seconds=600),
|
|
)
|
|
ticker = Ticker("BTCUSDT", 99.9, 99.89, 99.91, 1_000_000, 100, 0)
|
|
|
|
signal = strategy.exit_signal(
|
|
position,
|
|
candles,
|
|
ticker,
|
|
{
|
|
"adaptive_rules": {
|
|
"ema_exit_mode": "profit_only",
|
|
"min_exit_profit_percent": 0.31,
|
|
"min_hold_seconds": 60,
|
|
}
|
|
},
|
|
)
|
|
|
|
assert signal.action == "HOLD"
|
|
assert "EMA50" in signal.reason
|
|
|
|
|
|
def test_strategy_blocks_entry_when_learning_exposure_target_exceeded(make_settings, tmp_path) -> None:
|
|
settings = make_settings(tmp_path)
|
|
strategy = SpotStrategy(settings)
|
|
ticker = Ticker("BTCUSDT", 101, 100.99, 101.01, 10_000_000, 1000, 1.0)
|
|
signal = strategy.entry_signal(
|
|
"BTCUSDT",
|
|
_ready_candles(),
|
|
ticker,
|
|
open_positions_for_symbol=1,
|
|
learning={
|
|
"adaptive_rules": {
|
|
"over_target_exposure": True,
|
|
"target_total_exposure_usdt": 35,
|
|
"current_total_exposure_usdt": 80,
|
|
}
|
|
},
|
|
)
|
|
|
|
assert signal.action == "HOLD"
|
|
assert signal.diagnostics["entry_blocked_by_adaptive_rules"] is True
|
|
assert signal.diagnostics["adaptive_block_reason"] == "экспозиция выше цели обучения"
|
|
|
|
|
|
def test_strategy_learning_reduce_now_sells_after_min_hold(make_settings, tmp_path) -> None:
|
|
settings = make_settings(tmp_path, min_hold_seconds=60)
|
|
strategy = SpotStrategy(settings)
|
|
position = Position(
|
|
id=7,
|
|
symbol="BTCUSDT",
|
|
qty=1,
|
|
entry_price=100,
|
|
notional_usdt=100,
|
|
entry_fee_usdt=0.1,
|
|
stop_loss=90,
|
|
take_profit=120,
|
|
highest_price=101,
|
|
opened_at=utc_now() - timedelta(seconds=600),
|
|
)
|
|
ticker = Ticker("BTCUSDT", 99.5, 99.49, 99.51, 1_000_000, 100, 0)
|
|
|
|
signal = strategy.exit_signal(
|
|
position,
|
|
_ready_candles(),
|
|
ticker,
|
|
{"adaptive_rules": {"reduce_exposure": True, "reduce_now": True, "min_hold_seconds": 60}},
|
|
)
|
|
|
|
assert signal.action == "SELL"
|
|
assert "экспозицию" in signal.reason
|
|
|
|
|
|
def test_strategy_forecast_sells_to_lock_profit(make_settings, tmp_path) -> None:
|
|
settings = make_settings(tmp_path, min_hold_seconds=60)
|
|
strategy = SpotStrategy(settings)
|
|
position = Position(
|
|
id=9,
|
|
symbol="BTCUSDT",
|
|
qty=1,
|
|
entry_price=100,
|
|
notional_usdt=100,
|
|
entry_fee_usdt=0.1,
|
|
stop_loss=90,
|
|
take_profit=120,
|
|
highest_price=101.5,
|
|
opened_at=utc_now() - timedelta(seconds=600),
|
|
)
|
|
ticker = Ticker("BTCUSDT", 101, 100.99, 101.01, 1_000_000, 100, 0)
|
|
|
|
signal = strategy.exit_signal(
|
|
position,
|
|
_ready_candles(),
|
|
ticker,
|
|
forecast={
|
|
"usable": True,
|
|
"skill": 0.2,
|
|
"expected_return_percent": -0.2,
|
|
"probability_up": 0.35,
|
|
"reason": "тестовый негативный прогноз",
|
|
},
|
|
)
|
|
|
|
assert signal.action == "SELL"
|
|
assert "прогноз временного ряда" in signal.reason
|
|
|
|
|
|
def test_strategy_forecast_sells_to_limit_loss_before_stop(make_settings, tmp_path) -> None:
|
|
settings = make_settings(tmp_path, min_hold_seconds=60, stop_loss_percent=0.02)
|
|
strategy = SpotStrategy(settings)
|
|
position = Position(
|
|
id=10,
|
|
symbol="BTCUSDT",
|
|
qty=1,
|
|
entry_price=100,
|
|
notional_usdt=100,
|
|
entry_fee_usdt=0.1,
|
|
stop_loss=98,
|
|
take_profit=120,
|
|
highest_price=100.4,
|
|
opened_at=utc_now() - timedelta(seconds=600),
|
|
)
|
|
ticker = Ticker("BTCUSDT", 99.2, 99.19, 99.21, 1_000_000, 100, 0)
|
|
|
|
signal = strategy.exit_signal(
|
|
position,
|
|
_ready_candles(),
|
|
ticker,
|
|
forecast={
|
|
"usable": True,
|
|
"skill": 0.2,
|
|
"expected_return_percent": -0.2,
|
|
"probability_up": 0.35,
|
|
"reason": "тестовый негативный прогноз",
|
|
},
|
|
)
|
|
|
|
assert signal.action == "SELL"
|
|
assert "ограничиваем убыток" in signal.reason
|