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 _trend_entry_candles( *, macd_cross_up: bool = True, close: float = 105.0, ema50: float = 100.0, rsi: float = 55.0, ) -> list[Candle]: candles = [] for index in range(80): candles.append( Candle( timestamp=index, open=close - 0.4, high=close + 0.8, low=close - 0.8, close=close, volume=100, ema_20=close - 1.0, ema_50=ema50, ema_200=ema50 - 4.0, rsi_14=rsi, atr_14=1.0, macd=-0.1, macd_signal=0.0, macd_hist=-0.1, volume_ma_20=90, ) ) candles[-1].macd = 0.15 if macd_cross_up else -0.05 candles[-1].macd_signal = 0.0 candles[-1].macd_hist = candles[-1].macd - candles[-1].macd_signal return candles def _daily_trend_candles(*, uptrend: bool = True) -> list[Candle]: close = 110.0 if uptrend else 95.0 ema50 = 105.0 if uptrend else 98.0 ema200 = 100.0 return [ Candle( timestamp=index, open=close - 1.0, high=close + 1.0, low=close - 2.0, close=close, volume=1000, ema_20=ema50, ema_50=ema50, ema_200=ema200, rsi_14=55, atr_14=4.0, macd=1.0, macd_signal=0.8, macd_hist=0.2, volume_ma_20=900, ) for index in range(205) ] def test_trend_macd_buys_only_when_rules_pass(make_settings, tmp_path) -> None: settings = make_settings( tmp_path, strategy_mode="trend_macd", max_position_usdt=50, stop_loss_percent=0.04, risk_per_trade_percent=0.01, ) strategy = SpotStrategy(settings) ticker = Ticker("BTCUSDT", 105, 104.99, 105.01, 10_000_000, 1000, 1.0) signal = strategy.entry_signal( "BTCUSDT", _trend_entry_candles(), ticker, open_positions_for_symbol=0, account={"equity": 100.0}, trend_candles=_daily_trend_candles(), ) assert signal.action == "BUY" assert signal.diagnostics["strategy_mode"] == "trend_macd" assert signal.diagnostics["position_notional_usdt"] == 25.0 assert signal.diagnostics["position_sizing"]["risk_per_trade_percent"] == 1.0 def test_trend_macd_blocks_when_daily_trend_filter_fails(make_settings, tmp_path) -> None: settings = make_settings(tmp_path, strategy_mode="trend_macd") strategy = SpotStrategy(settings) ticker = Ticker("ETHUSDT", 105, 104.99, 105.01, 10_000_000, 1000, 1.0) signal = strategy.entry_signal( "ETHUSDT", _trend_entry_candles(), ticker, open_positions_for_symbol=0, account={"equity": 100.0}, trend_candles=_daily_trend_candles(uptrend=False), ) assert signal.action == "HOLD" assert signal.diagnostics["checks"]["daily_trend_ok"] is False def test_trend_macd_blocks_second_position_for_symbol(make_settings, tmp_path) -> None: settings = make_settings(tmp_path, strategy_mode="trend_macd") strategy = SpotStrategy(settings) ticker = Ticker("SOLUSDT", 105, 104.99, 105.01, 10_000_000, 1000, 1.0) signal = strategy.entry_signal( "SOLUSDT", _trend_entry_candles(), ticker, open_positions_for_symbol=1, account={"equity": 100.0}, trend_candles=_daily_trend_candles(), ) assert signal.action == "HOLD" assert "позиция" in signal.reason def test_trend_macd_exits_on_macd_cross_down(make_settings, tmp_path) -> None: settings = make_settings(tmp_path, strategy_mode="trend_macd") strategy = SpotStrategy(settings) candles = _trend_entry_candles() candles[-2].macd = 0.2 candles[-2].macd_signal = 0.0 candles[-1].macd = -0.1 candles[-1].macd_signal = 0.0 position = Position(1, "BTCUSDT", 1, 100, 100, 0.1, 96, 120, 100) ticker = Ticker("BTCUSDT", 104, 103.99, 104.01, 1_000_000, 100, 0) signal = strategy.exit_signal(position, candles, ticker) assert signal.action == "SELL" assert "MACD" in signal.reason def test_trend_macd_exits_on_close_below_ema50(make_settings, tmp_path) -> None: settings = make_settings(tmp_path, strategy_mode="trend_macd") strategy = SpotStrategy(settings) candles = _trend_entry_candles(close=99.0, ema50=100.0, macd_cross_up=False) candles[-2].macd = 0.1 candles[-2].macd_signal = 0.0 candles[-1].macd = 0.05 candles[-1].macd_signal = 0.0 position = Position(1, "BTCUSDT", 1, 100, 100, 0.1, 96, 120, 100) ticker = Ticker("BTCUSDT", 99, 98.99, 99.01, 1_000_000, 100, 0) signal = strategy.exit_signal(position, candles, ticker) assert signal.action == "SELL" assert "EMA50" in signal.reason def test_trend_macd_exits_on_atr_trailing_stop(make_settings, tmp_path) -> None: settings = make_settings(tmp_path, strategy_mode="trend_macd", atr_trailing_multiplier=2.2) strategy = SpotStrategy(settings) candles = _trend_entry_candles(close=108.0, ema50=100.0, macd_cross_up=False) candles[-2].macd = 0.1 candles[-2].macd_signal = 0.0 candles[-1].macd = 0.05 candles[-1].macd_signal = 0.0 candles[-1].atr_14 = 1.0 position = Position(1, "BTCUSDT", 1, 100, 100, 0.1, 96, 120, 110) ticker = Ticker("BTCUSDT", 107.7, 107.69, 107.71, 1_000_000, 100, 0) signal = strategy.exit_signal(position, candles, ticker) assert signal.action == "SELL" assert "ATR trailing" in signal.reason def test_torch_forecast_buys_only_from_positive_torch_edge(make_settings, tmp_path) -> None: settings = make_settings( tmp_path, strategy_mode="torch_forecast", max_position_usdt=25, stop_loss_percent=0.04, risk_per_trade_percent=0.01, ) strategy = SpotStrategy(settings) ticker = Ticker("BTCUSDT", 105, 104.99, 105.01, 10_000_000, 1000, 1.0) signal = strategy.entry_signal( "BTCUSDT", [], ticker, open_positions_for_symbol=0, forecast={ "usable": True, "model": "torch_gru", "expected_return_percent": 0.36, "probability_up": 0.66, "skill": 0.22, "block_entry": False, }, account={"equity": 100.0}, ) assert signal.action == "BUY" assert signal.diagnostics["strategy_mode"] == "torch_forecast" assert signal.diagnostics["checks"]["torch_model_ok"] is True assert signal.diagnostics["position_sizing"]["method"] == "torch_forecast_fractional_kelly" assert settings.min_position_usdt <= signal.diagnostics["position_notional_usdt"] <= settings.max_position_usdt def test_torch_forecast_blocks_without_valid_torch_model(make_settings, tmp_path) -> None: settings = make_settings(tmp_path, strategy_mode="torch_forecast") strategy = SpotStrategy(settings) ticker = Ticker("ETHUSDT", 105, 104.99, 105.01, 10_000_000, 1000, 1.0) signal = strategy.entry_signal( "ETHUSDT", _trend_entry_candles(), ticker, open_positions_for_symbol=0, forecast={"usable": True, "model": "none", "expected_return_percent": 0.5, "probability_up": 0.7}, account={"equity": 100.0}, ) assert signal.action == "HOLD" assert signal.diagnostics["checks"]["torch_model_ok"] is False def test_torch_forecast_allows_additional_entries_until_symbol_limit(make_settings, tmp_path) -> None: settings = make_settings( tmp_path, strategy_mode="torch_forecast", min_position_usdt=1, max_symbol_exposure_usdt=3, max_positions_per_symbol=3, max_position_usdt=25, stop_loss_percent=0.04, risk_per_trade_percent=0.01, ) strategy = SpotStrategy(settings) ticker = Ticker("BTCUSDT", 105, 104.99, 105.01, 10_000_000, 1000, 1.0) forecast = { "usable": True, "model": "torch_gru", "expected_return_percent": 0.36, "probability_up": 0.66, "skill": 0.22, "block_entry": False, } additional = strategy.entry_signal( "BTCUSDT", [], ticker, open_positions_for_symbol=1, forecast=forecast, account={"equity": 100.0}, ) capped = strategy.entry_signal( "BTCUSDT", [], ticker, open_positions_for_symbol=3, forecast=forecast, account={"equity": 100.0}, ) assert additional.action == "BUY" assert capped.action == "HOLD" assert "symbol position limit" in capped.reason def test_torch_forecast_kelly_buys_only_remaining_symbol_allocation(make_settings, tmp_path) -> None: settings = make_settings( tmp_path, strategy_mode="torch_forecast", min_position_usdt=1, max_position_usdt=8, max_symbol_exposure_usdt=25, max_positions_per_symbol=6, stop_loss_percent=0.04, take_profit_percent=0.035, kelly_sizing_enabled=True, kelly_fraction=0.25, kelly_max_fraction=0.20, time_series_min_edge_percent=0.10, time_series_min_probability_up=0.47, ) strategy = SpotStrategy(settings) ticker = Ticker("BTCUSDT", 105, 104.99, 105.01, 10_000_000, 1000, 1.0) forecast = { "usable": True, "model": "torch_gru", "expected_return_percent": 0.60, "probability_up": 0.84, "skill": 0.22, "block_entry": False, } first = strategy.entry_signal( "BTCUSDT", [], ticker, open_positions_for_symbol=0, forecast=forecast, account={"equity": 100.0, "symbol": "BTCUSDT", "symbol_exposure_usdt": 0.0}, ) second = strategy.entry_signal( "BTCUSDT", [], ticker, open_positions_for_symbol=1, forecast=forecast, account={"equity": 100.0, "symbol": "BTCUSDT", "symbol_exposure_usdt": 8.0}, ) filled = strategy.entry_signal( "BTCUSDT", [], ticker, open_positions_for_symbol=2, forecast=forecast, account={"equity": 100.0, "symbol": "BTCUSDT", "symbol_exposure_usdt": 20.0}, ) first_sizing = first.diagnostics["position_sizing"] second_sizing = second.diagnostics["position_sizing"] assert first.action == "BUY" assert first_sizing["method"] == "torch_forecast_fractional_kelly" assert first_sizing["kelly_target_notional_usdt"] > settings.max_position_usdt assert first.diagnostics["position_notional_usdt"] == settings.max_position_usdt assert second.action == "BUY" assert 1 <= second.diagnostics["position_notional_usdt"] < settings.max_position_usdt assert second_sizing["kelly_open_symbol_exposure_usdt"] == 8.0 assert filled.action == "HOLD" assert filled.diagnostics["checks"]["risk_size_ok"] is False def test_torch_forecast_blocks_failed_quality_gate(make_settings, tmp_path) -> None: settings = make_settings( tmp_path, strategy_mode="torch_forecast", time_series_min_edge_percent=0.10, time_series_min_probability_up=0.57, max_position_usdt=25, stop_loss_percent=0.04, ) strategy = SpotStrategy(settings) ticker = Ticker("BTCUSDT", 105, 104.99, 105.01, 10_000_000, 1000, 1.0) signal = strategy.entry_signal( "BTCUSDT", [], ticker, open_positions_for_symbol=0, forecast={ "usable": True, "model": "torch_gru", "expected_return_percent": 0.36, "probability_up": 0.66, "skill": 0.22, "block_entry": False, "quality_gate_passed": False, "quality_gate": {"status": "fail"}, }, account={"equity": 100.0}, ) assert signal.action == "HOLD" assert signal.diagnostics["checks"]["quality_gate_ok"] is False def test_torch_forecast_probe_blocks_when_kelly_size_is_too_small(make_settings, tmp_path) -> None: settings = make_settings( tmp_path, strategy_mode="torch_forecast", time_series_min_edge_percent=0.10, time_series_min_probability_up=0.52, time_series_probe_enabled=True, time_series_probe_min_edge_percent=0.02, time_series_probe_min_probability_up=0.55, time_series_probe_size_multiplier=0.40, max_position_usdt=25, stop_loss_percent=0.04, risk_per_trade_percent=0.01, ) strategy = SpotStrategy(settings) ticker = Ticker("SOLUSDT", 65, 64.99, 65.01, 10_000_000, 1000, 1.0) signal = strategy.entry_signal( "SOLUSDT", [], ticker, open_positions_for_symbol=0, forecast={ "usable": True, "model": "torch_gru", "expected_return_percent": 0.04, "probability_up": 0.57, "skill": 0.05, "block_entry": False, }, account={"equity": 100.0}, ) assert signal.action == "HOLD" assert signal.diagnostics["edge_mode"] == "probe" assert signal.diagnostics["checks"]["expected_edge_ok"] is True assert signal.diagnostics["checks"]["risk_size_ok"] is False assert signal.diagnostics["position_notional_usdt"] == 0.0 def test_torch_forecast_probe_blocks_negative_expected_return(make_settings, tmp_path) -> None: settings = make_settings( tmp_path, strategy_mode="torch_forecast", time_series_min_edge_percent=0.10, time_series_min_probability_up=0.52, time_series_probe_enabled=True, time_series_probe_min_edge_percent=0.02, time_series_probe_min_probability_up=0.55, ) strategy = SpotStrategy(settings) ticker = Ticker("BTCUSDT", 59_000, 58_999, 59_001, 10_000_000, 1000, 1.0) signal = strategy.entry_signal( "BTCUSDT", [], ticker, open_positions_for_symbol=0, forecast={ "usable": True, "model": "torch_gru", "expected_return_percent": -0.03, "probability_up": 0.60, "skill": 0.16, "block_entry": False, }, account={"equity": 100.0}, ) assert signal.action == "HOLD" assert signal.diagnostics["edge_mode"] == "blocked" assert signal.diagnostics["checks"]["expected_edge_ok"] is False def test_torch_forecast_rebound_overlay_blocks_when_kelly_size_is_too_small(make_settings, tmp_path) -> None: settings = make_settings( tmp_path, strategy_mode="torch_forecast", rebound_trading_enabled=True, rebound_entry_confidence=0.55, rebound_min_probability=0.55, rebound_max_position_usdt=6.0, time_series_min_edge_percent=0.10, time_series_probe_min_probability_up=0.55, max_position_usdt=25, stop_loss_percent=0.04, risk_per_trade_percent=0.01, ) 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}, forecast={ "usable": True, "model": "torch_gru", "expected_return_percent": 0.01, "probability_up": 0.56, "skill": 0.05, "block_entry": False, }, account={"equity": 100.0}, ) assert signal.action == "HOLD" assert signal.diagnostics["rebound"]["active"] is True assert signal.diagnostics["model_rebound_entry_ok"] is True assert signal.diagnostics["rebound_entry_sized_ok"] is False assert signal.diagnostics["checks"]["expected_edge_ok"] is False assert signal.diagnostics["checks"]["risk_size_ok"] is False def test_torch_forecast_rebound_overlay_does_not_buy_negative_forecast(make_settings, tmp_path) -> None: settings = make_settings( tmp_path, strategy_mode="torch_forecast", rebound_trading_enabled=True, rebound_entry_confidence=0.55, rebound_min_probability=0.55, time_series_min_edge_percent=0.10, time_series_probe_min_probability_up=0.55, ) 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}, forecast={ "usable": True, "model": "torch_gru", "expected_return_percent": -0.01, "probability_up": 0.56, "skill": 0.05, "block_entry": False, }, account={"equity": 100.0}, ) assert signal.action == "HOLD" assert signal.diagnostics["rebound"]["active"] is True assert signal.diagnostics["rebound_entry_ok"] is False assert signal.diagnostics["edge_mode"] == "blocked" def test_torch_forecast_rebound_fallback_blocks_when_kelly_size_is_too_small(make_settings, tmp_path) -> None: settings = make_settings( tmp_path, strategy_mode="torch_forecast", rebound_trading_enabled=True, rebound_entry_confidence=0.55, rebound_min_probability=0.55, rebound_max_position_usdt=6.0, time_series_rebound_fallback_enabled=True, stop_loss_percent=0.04, risk_per_trade_percent=0.01, ) strategy = SpotStrategy(settings) candles = _rebound_candles() ticker = Ticker( symbol="HYPEUSDT", 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( "HYPEUSDT", candles, ticker, open_positions_for_symbol=0, pattern={"label": "нисходящий тренд", "score": 0.28}, forecast={ "usable": False, "model": "none", "reason": "no valid PyTorch LSTM/GRU model for symbol", "block_entry": False, }, account={"equity": 100.0}, ) assert signal.action == "HOLD" assert signal.diagnostics["fallback_rebound_entry_ok"] is True assert signal.diagnostics["rebound_entry_sized_ok"] is False assert signal.diagnostics["missing_torch_model"] is True assert signal.diagnostics["checks"]["risk_size_ok"] is False def test_torch_forecast_rebound_fallback_can_be_disabled(make_settings, tmp_path) -> None: settings = make_settings( tmp_path, strategy_mode="torch_forecast", rebound_trading_enabled=True, rebound_entry_confidence=0.55, rebound_min_probability=0.55, time_series_rebound_fallback_enabled=False, ) strategy = SpotStrategy(settings) candles = _rebound_candles() ticker = Ticker( symbol="HYPEUSDT", 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( "HYPEUSDT", candles, ticker, open_positions_for_symbol=0, pattern={"label": "нисходящий тренд", "score": 0.28}, forecast={ "usable": False, "model": "none", "reason": "no valid PyTorch LSTM/GRU model for symbol", "block_entry": False, }, account={"equity": 100.0}, ) assert signal.action == "HOLD" assert signal.diagnostics["missing_torch_model"] is True assert signal.diagnostics["fallback_rebound_entry_ok"] is False def test_torch_forecast_exits_when_forecast_turns_negative(make_settings, tmp_path) -> None: settings = make_settings(tmp_path, strategy_mode="torch_forecast", stop_loss_percent=0.04) strategy = SpotStrategy(settings) position = Position(1, "SOLUSDT", 1, 100, 100, 0.1, 96, 120, 103) ticker = Ticker("SOLUSDT", 101, 100.99, 101.01, 10_000_000, 1000, 1.0) signal = strategy.exit_signal( position, _trend_entry_candles(), ticker, forecast={ "usable": True, "model": "torch_lstm", "expected_return_percent": -0.08, "probability_up": 0.43, "skill": 0.18, "block_entry": True, }, ) assert signal.action == "SELL" assert "torch_forecast" in signal.reason def test_torch_forecast_rebound_fallback_holds_without_model(make_settings, tmp_path) -> None: settings = make_settings(tmp_path, strategy_mode="torch_forecast", min_hold_seconds=180) strategy = SpotStrategy(settings) position = Position( 1, "XRPUSDT", 5, 1.0, 5.0, 0.005, 0.96, 1.035, 1.0, entry_diagnostics={"entry_path": "rebound_fallback", "edge_mode": "rebound_fallback"}, ) ticker = Ticker("XRPUSDT", 1.001, 1.0009, 1.0011, 10_000_000, 1000, 1.0) signal = strategy.exit_signal( position, _trend_entry_candles(close=1.0, ema50=0.98), ticker, forecast={ "usable": False, "model": "none", "reason": "no valid PyTorch LSTM/GRU model for symbol", "block_entry": False, }, ) assert signal.action == "HOLD" assert signal.diagnostics["rebound_fallback_position"] is True assert "rebound fallback" in signal.reason def test_torch_forecast_rebound_fallback_still_sells_take_profit(make_settings, tmp_path) -> None: settings = make_settings(tmp_path, strategy_mode="torch_forecast") strategy = SpotStrategy(settings) position = Position( 1, "XRPUSDT", 5, 1.0, 5.0, 0.005, 0.96, 1.035, 1.04, opened_at=utc_now() - timedelta(seconds=600), entry_diagnostics={"entry_path": "rebound_fallback", "edge_mode": "rebound_fallback"}, ) ticker = Ticker("XRPUSDT", 1.036, 1.0359, 1.0361, 10_000_000, 1000, 1.0) signal = strategy.exit_signal( position, _trend_entry_candles(close=1.0, ema50=0.98), ticker, forecast={ "usable": False, "model": "none", "reason": "no valid PyTorch LSTM/GRU model for symbol", "block_entry": False, }, ) assert signal.action == "SELL" assert "take-profit" in signal.reason 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