from __future__ import annotations from crypto_spot_bot.config import Settings from crypto_spot_bot.models import Candle, Position, Signal, Ticker, utc_now NEGATIVE_LONG_PATTERNS = {"нисходящий тренд", "пробой вниз", "ускоренное падение"} class SpotStrategy: def __init__(self, settings: Settings): self.settings = settings def entry_signal( self, symbol: str, candles: list[Candle], ticker: Ticker | None, open_positions_for_symbol: int, pattern: dict | None = None, learning: dict | None = None, llm: dict | None = None, forecast: dict | None = None, ) -> Signal: if ticker is None: return Signal(symbol, "HOLD", 0.0, "нет ticker-данных") if len(candles) < 200: return Signal(symbol, "HOLD", 0.0, "недостаточно свечей для EMA200") latest = candles[-1] previous = candles[-2] if len(candles) >= 2 else latest if not _has_entry_indicators(latest): return Signal(symbol, "HOLD", 0.0, "индикаторы еще не готовы") spread_ok = ticker.spread_percent <= self.settings.max_spread_percent liquidity_ok = ticker.turnover_24h >= self.settings.min_24h_turnover_usdt trend_ok = latest.close > latest.ema_200 or latest.ema_20 > latest.ema_50 pullback_ok = 35 <= latest.rsi_14 <= 58 and latest.close <= latest.ema_20 * 1.012 momentum_ok = latest.ema_20 >= latest.ema_50 or latest.close > previous.close volume_ok = latest.volume_ma_20 is not None and latest.volume >= latest.volume_ma_20 * 0.75 atr_percent = (latest.atr_14 / latest.close) * 100 if latest.close else 0.0 volatility_ok = 0.04 <= atr_percent <= 6.0 weights = { "spread": 0.18, "liquidity": 0.14, "trend": 0.16, "pullback": 0.18, "momentum": 0.14, "volume": 0.10, "volatility": 0.10, } score = ( weights["spread"] * float(spread_ok) + weights["liquidity"] * float(liquidity_ok) + weights["trend"] * float(trend_ok) + weights["pullback"] * float(pullback_ok) + weights["momentum"] * float(momentum_ok) + weights["volume"] * float(volume_ok) + weights["volatility"] * float(volatility_ok) ) pattern = pattern or {} learning = learning or {} llm = llm or {} forecast = forecast or {} pattern_label = str(pattern.get("label") or "") pattern_score = float(pattern.get("score", 0.5) or 0.5) pattern_adjustment = ( (pattern_score - 0.5) * self.settings.pattern_score_weight if self.settings.pattern_analysis_enabled else 0.0 ) learning_adjustment = float(learning.get("confidence_adjustment", 0.0) or 0.0) forecast_adjustment = ( float(forecast.get("confidence_adjustment", 0.0) or 0.0) if self.settings.time_series_forecast_enabled else 0.0 ) adaptive = _adaptive_rules(learning) adaptive_entry_adjustment = _adaptive_threshold_adjustment(adaptive) falling_market = _falling_market(latest, previous, pattern_label, llm) llm_adjustment = float(llm.get("confidence_adjustment", 0.0) or 0.0) rebound = _rebound_state( settings=self.settings, candles=candles, latest=latest, previous=previous, pattern=pattern, llm=llm, spread_ok=spread_ok, liquidity_ok=liquidity_ok, volume_ok=volume_ok, volatility_ok=volatility_ok, atr_percent=atr_percent, ) adaptive_blocks_entry = _adaptive_blocks_entry(adaptive, falling_market, rebound["active"]) base_final_score = score + pattern_adjustment + learning_adjustment + llm_adjustment + forecast_adjustment rebound_entry_score = float(rebound.get("entry_score", 0.0) or 0.0) final_score = _clamp(max(base_final_score, rebound_entry_score), 0.0, 1.0) learning_blocks_entry = _learning_blocks_entry( learning=learning, learning_adjustment=learning_adjustment, min_samples=self.settings.learning_min_samples, max_adjustment=self.settings.learning_max_adjustment, enabled=self.settings.learning_enabled, ) llm_blocks_entry = bool(llm.get("block_entry", False)) and self.settings.llm_advisor_enabled forecast_blocks_entry = ( bool(forecast.get("block_entry", False)) and self.settings.time_series_forecast_enabled and bool(forecast.get("usable", False)) ) grid = _grid_state( settings=self.settings, latest=latest, pattern=pattern, llm=llm, atr_percent=atr_percent, spread_ok=spread_ok, liquidity_ok=liquidity_ok, volatility_ok=volatility_ok, ) base_entry_threshold = ( self.settings.grid_entry_confidence if grid["active"] else self.settings.rebound_entry_confidence if rebound["active"] else self.settings.min_signal_confidence ) entry_threshold = _clamp(base_entry_threshold + adaptive_entry_adjustment, 0.45, 0.92) negative_pattern = ( self.settings.pattern_analysis_enabled and pattern_label in NEGATIVE_LONG_PATTERNS and pattern_score <= 0.32 ) pattern_blocks_entry = negative_pattern and not ( rebound["active"] and rebound_entry_score >= entry_threshold ) position_notional = _position_notional( settings=self.settings, final_score=final_score, grid_active=grid["active"], rebound_active=rebound["active"], llm=llm, forecast=forecast, adaptive=adaptive, ) trade_mode = "GRID" if grid["active"] else "REBOUND" if rebound["active"] else "NORMAL" diagnostics = { "base_score": round(score, 4), "pattern_adjustment": round(pattern_adjustment, 4), "learning_adjustment": round(learning_adjustment, 4), "llm_adjustment": round(llm_adjustment, 4), "forecast_adjustment": round(forecast_adjustment, 4), "rebound_probability": rebound["probability"], "rebound_entry_score": round(rebound_entry_score, 4), "final_score": round(final_score, 4), "entry_blocked_by_pattern": pattern_blocks_entry, "entry_blocked_by_learning": learning_blocks_entry, "entry_blocked_by_adaptive_rules": adaptive_blocks_entry, "adaptive_block_reason": _adaptive_block_reason(adaptive, falling_market, rebound["active"]), "entry_blocked_by_llm": llm_blocks_entry, "entry_blocked_by_forecast": forecast_blocks_entry, "falling_market": falling_market, "open_positions_for_symbol": open_positions_for_symbol, "position_notional_usdt": position_notional, "trade_mode": trade_mode, "base_entry_threshold": round(base_entry_threshold, 4), "adaptive_entry_threshold_adjustment": round(adaptive_entry_adjustment, 4), "entry_threshold": round(entry_threshold, 4), "adaptive_rules": adaptive, "stop_loss_percent": _adaptive_percent( adaptive, "stop_loss_percent", self.settings.stop_loss_percent, 0.003, 0.08 ), "take_profit_percent": _adaptive_percent( adaptive, "take_profit_percent", self.settings.take_profit_percent, 0.003, 0.20 ), "trailing_stop_percent": _adaptive_percent( adaptive, "trailing_stop_percent", self.settings.trailing_stop_percent, 0.003, 0.08 ), "grid": grid, "rebound": rebound, "pattern": pattern, "learning": learning, "llm": llm, "forecast": forecast, "spread_percent": round(ticker.spread_percent, 5), "turnover_24h": ticker.turnover_24h, "rsi_14": latest.rsi_14, "ema_20": latest.ema_20, "ema_50": latest.ema_50, "ema_200": latest.ema_200, "volume": latest.volume, "volume_ma_20": latest.volume_ma_20, "atr_percent": atr_percent, "checks": { "spread_ok": spread_ok, "liquidity_ok": liquidity_ok, "trend_ok": trend_ok, "pullback_ok": pullback_ok, "momentum_ok": momentum_ok, "volume_ok": volume_ok, "volatility_ok": volatility_ok, "rebound_active": rebound["active"], }, } suffix = _decision_suffix(pattern, learning, llm) if pattern_blocks_entry: return Signal( symbol, "HOLD", round(final_score, 4), f"покупка заблокирована отрицательным LONG-шаблоном: {pattern_label}{suffix}", diagnostics, ) if learning_blocks_entry: return Signal( symbol, "HOLD", round(final_score, 4), f"покупка заблокирована обучением: похожие сделки были убыточными{suffix}", diagnostics, ) if adaptive_blocks_entry: return Signal( symbol, "HOLD", round(final_score, 4), f"покупка заблокирована адаптивными правилами обучения: символ или шаблон в стоп-листе{suffix}", diagnostics, ) if llm_blocks_entry: return Signal( symbol, "HOLD", round(final_score, 4), f"покупка заблокирована LLM Advisor: {llm.get('reason_ru') or 'модель вернула block_entry=true'}{suffix}", diagnostics, ) if forecast_blocks_entry: return Signal( symbol, "HOLD", round(final_score, 4), f"покупка заблокирована прогнозом временного ряда: {forecast.get('reason') or 'ожидаемое движение вниз'}{suffix}", diagnostics, ) if grid["active"] and not grid["buy_zone"] and not rebound["active"]: return Signal( symbol, "HOLD", round(final_score, 4), f"grid-режим активен, но цена не в зоне покупки: {grid['reason']}{suffix}", diagnostics, ) if final_score >= entry_threshold: mode_reason = ( f"grid-режим: покупка в нижней части диапазона, размер {position_notional:.2f} USDT" if grid["active"] else f"rebound-сценарий: падение стабилизировалось, вероятность {rebound['probability']:.2f}, размер {position_notional:.2f} USDT" if rebound["active"] else f"условия покупки набрали достаточную оценку, размер {position_notional:.2f} USDT" ) return Signal( symbol, "BUY", round(final_score, 4), f"{mode_reason}{suffix}", diagnostics, ) return Signal( symbol, "HOLD", round(final_score, 4), f"оценка входа ниже порога{suffix}", diagnostics, ) def _legacy_exit_signal( self, position: Position, candles: list[Candle], ticker: Ticker | None, learning: dict | None = None, ) -> Signal: if ticker is None: return Signal(position.symbol, "HOLD", 0.0, "нет ticker-данных для выхода") if not candles: return Signal(position.symbol, "HOLD", 0.0, "нет свечей для выхода") latest = candles[-1] previous = candles[-2] if len(candles) >= 2 else latest price = ticker.last_price trailing = position.trailing_stop(self.settings.trailing_stop_percent) diagnostics = { "price": price, "entry_price": position.entry_price, "stop_loss": position.stop_loss, "take_profit": position.take_profit, "highest_price": position.highest_price, "trailing_stop": trailing, "rsi_14": latest.rsi_14, "ema_20": latest.ema_20, "ema_50": latest.ema_50, } if price <= position.stop_loss: return Signal(position.symbol, "SELL", 1.0, "сработал стоп-лосс", diagnostics) if price >= position.take_profit: return Signal(position.symbol, "SELL", 0.96, "сработал тейк-профит", diagnostics) if trailing is not None and price <= trailing: return Signal(position.symbol, "SELL", 0.90, "сработал трейлинг-стоп выше цены входа", diagnostics) hold_seconds = (utc_now() - position.opened_at).total_seconds() diagnostics["hold_seconds"] = hold_seconds if hold_seconds < self.settings.min_hold_seconds: return Signal(position.symbol, "HOLD", 0.45, "минимальное время удержания еще не прошло", diagnostics) if adaptive.get("reduce_exposure") and adaptive.get("reduce_now"): return Signal( position.symbol, "SELL", 0.88, "обучение снижает общую экспозицию до целевого уровня", diagnostics, ) if latest.rsi_14 is not None and latest.rsi_14 >= 72 and latest.close < previous.close: return Signal(position.symbol, "SELL", 0.76, "RSI высокий и цена начала снижаться", diagnostics) if ( latest.ema_20 is not None and latest.ema_50 is not None and latest.ema_20 < latest.ema_50 and latest.close < latest.ema_50 ): return Signal(position.symbol, "SELL", 0.70, "краткосрочный тренд ослаб ниже EMA50", diagnostics) return Signal(position.symbol, "HOLD", 0.35, "условия выхода не выполнены", diagnostics) def exit_signal( self, position: Position, candles: list[Candle], ticker: Ticker | None, learning: dict | None = None, forecast: dict | None = None, ) -> Signal: if ticker is None: return Signal(position.symbol, "HOLD", 0.0, "нет ticker-данных для выхода") if not candles: return Signal(position.symbol, "HOLD", 0.0, "нет свечей для выхода") latest = candles[-1] previous = candles[-2] if len(candles) >= 2 else latest price = ticker.last_price adaptive = _adaptive_rules(learning or {}) forecast = forecast or {} stop_loss_percent = _adaptive_percent( adaptive, "stop_loss_percent", self.settings.stop_loss_percent, 0.003, 0.08 ) take_profit_percent = _adaptive_percent( adaptive, "take_profit_percent", self.settings.take_profit_percent, 0.003, 0.20 ) trailing_percent = _adaptive_percent( adaptive, "trailing_stop_percent", self.settings.trailing_stop_percent, 0.003, 0.08 ) effective_stop_loss = max(position.stop_loss, position.entry_price * (1 - stop_loss_percent)) effective_take_profit = position.entry_price * (1 + take_profit_percent) trailing = position.trailing_stop(trailing_percent) estimated_exit_net_percent = _estimated_exit_net_percent(position, price, self.settings) diagnostics = { "price": price, "entry_price": position.entry_price, "stop_loss": effective_stop_loss, "take_profit": effective_take_profit, "highest_price": position.highest_price, "trailing_stop": trailing, "rsi_14": latest.rsi_14, "ema_20": latest.ema_20, "ema_50": latest.ema_50, "adaptive_rules": adaptive, "forecast": forecast, "estimated_exit_net_percent": round(estimated_exit_net_percent, 4), "min_exit_profit_percent": float(adaptive.get("min_exit_profit_percent", 0.0) or 0.0), } if price <= effective_stop_loss: return Signal(position.symbol, "SELL", 1.0, "сработал стоп-лосс", diagnostics) if price >= effective_take_profit: return Signal(position.symbol, "SELL", 0.96, "сработал тейк-профит", diagnostics) if trailing is not None and price <= trailing: return Signal(position.symbol, "SELL", 0.90, "сработал трейлинг-стоп выше цены входа", diagnostics) hold_seconds = (utc_now() - position.opened_at).total_seconds() diagnostics["hold_seconds"] = hold_seconds adaptive_min_hold = int(float(adaptive.get("min_hold_seconds", self.settings.min_hold_seconds) or 0)) min_hold_seconds = max(self.settings.min_hold_seconds, adaptive_min_hold) diagnostics["min_hold_seconds"] = min_hold_seconds if adaptive.get("reduce_exposure") and adaptive.get("reduce_now") and hold_seconds >= min_hold_seconds: return Signal( position.symbol, "SELL", 0.88, "обучение снижает общую экспозицию до целевого уровня", diagnostics, ) if hold_seconds < min_hold_seconds: return Signal(position.symbol, "HOLD", 0.45, "минимальное время удержания еще не прошло", diagnostics) forecast_exit = _forecast_exit_signal( forecast=forecast, position=position, price=price, estimated_exit_net_percent=estimated_exit_net_percent, stop_loss_percent=stop_loss_percent, min_edge_percent=self.settings.time_series_min_edge_percent, ) if forecast_exit is not None: action, confidence, reason = forecast_exit return Signal(position.symbol, action, confidence, reason, diagnostics) if latest.rsi_14 is not None and latest.rsi_14 >= 72 and latest.close < previous.close: if _adaptive_indicator_exit_allowed(adaptive, "rsi_exit_mode", estimated_exit_net_percent): return Signal(position.symbol, "SELL", 0.76, "RSI высокий и цена начала снижаться", diagnostics) return Signal( position.symbol, "HOLD", 0.44, "обучение удерживает позицию: RSI-выход убыточен после издержек", diagnostics, ) if ( latest.ema_20 is not None and latest.ema_50 is not None and latest.ema_20 < latest.ema_50 and latest.close < latest.ema_50 ): if _adaptive_indicator_exit_allowed(adaptive, "ema_exit_mode", estimated_exit_net_percent): return Signal(position.symbol, "SELL", 0.70, "краткосрочный тренд ослаб ниже EMA50", diagnostics) return Signal( position.symbol, "HOLD", 0.44, "обучение удерживает позицию: EMA50-выход убыточен после издержек", diagnostics, ) return Signal(position.symbol, "HOLD", 0.35, "условия выхода не выполнены", diagnostics) def _has_entry_indicators(candle: Candle) -> bool: return all( value is not None for value in ( candle.ema_20, candle.ema_50, candle.ema_200, candle.rsi_14, candle.atr_14, candle.volume_ma_20, ) ) def _decision_suffix(pattern: dict, learning: dict, llm: dict | None = None) -> str: parts: list[str] = [] label = pattern.get("label") if label: parts.append(f"шаблон: {label}") reason = learning.get("reason") adjustment = float(learning.get("confidence_adjustment", 0.0) or 0.0) if reason and adjustment != 0: parts.append(f"обучение: {reason}") llm = llm or {} llm_reason = llm.get("reason_ru") llm_adjustment = float(llm.get("confidence_adjustment", 0.0) or 0.0) if llm_reason and (llm_adjustment != 0 or llm.get("block_entry")): parts.append(f"LLM: {llm_reason}") return " (" + "; ".join(parts) + ")" if parts else "" def _clamp(value: float, low: float, high: float) -> float: return max(low, min(high, value)) def _position_notional( *, settings: Settings, final_score: float, grid_active: bool, rebound_active: bool, llm: dict, forecast: dict | None = None, adaptive: dict | None = None, ) -> float: low = max(0.0, settings.min_position_usdt) high = max(low, settings.max_position_usdt) if grid_active: high = max(low, min(high, settings.grid_max_position_usdt)) elif rebound_active: high = max(low, min(high, settings.rebound_max_position_usdt)) denominator = max(0.0001, 1.0 - settings.min_signal_confidence) confidence_ratio = _clamp((final_score - settings.min_signal_confidence) / denominator, 0.0, 1.0) raw = low + (high - low) * confidence_ratio risk = str(llm.get("risk_level", "medium")).lower() if risk == "high": raw *= 0.55 elif risk == "low": raw *= 1.10 forecast = forecast or {} if forecast.get("usable"): probability_up = _safe_float(forecast.get("probability_up"), 0.5) volatility_percent = _safe_float(forecast.get("volatility_percent"), 0.0) if probability_up < 0.52: raw *= 0.75 elif probability_up >= 0.60: raw *= 1.08 if volatility_percent >= 0.8: raw *= 0.70 risk_mode = str((adaptive or {}).get("risk_mode", "neutral")).lower() if risk_mode == "defensive": raw *= 0.65 elif risk_mode == "expansion": raw *= 1.10 return round(_clamp(raw, low, high), 2) def _grid_state( *, settings: Settings, latest: Candle, pattern: dict, llm: dict, atr_percent: float, spread_ok: bool, liquidity_ok: bool, volatility_ok: bool, ) -> dict: metrics = pattern.get("metrics") or {} high20 = _safe_float(metrics.get("high20"), latest.high) low20 = _safe_float(metrics.get("low20"), latest.low) width = max(0.0, high20 - low20) range_position = _clamp((latest.close - low20) / width, 0.0, 1.0) if width else 0.5 range_width_percent = (width / latest.close * 100) if latest.close else 0.0 label = str(pattern.get("label", "")).lower() tags = {str(tag).lower() for tag in pattern.get("tags", [])} llm_regime = str(llm.get("market_regime", "")).lower() llm_grid = bool(llm.get("grid_suitable", False)) ema_gap = abs(_safe_float(metrics.get("ema_gap_percent"), 999.0)) ret_20 = abs(_safe_float(metrics.get("ret_20_percent"), 999.0)) range_like = ( "боковик" in label or "боковик" in tags or llm_regime == "range" or llm_grid or (ema_gap <= 0.35 and ret_20 <= max(0.8, atr_percent * 1.2)) ) dangerous = ( label in NEGATIVE_LONG_PATTERNS or llm_regime in {"downtrend", "breakdown", "panic"} or bool(llm.get("block_entry", False)) ) active = bool( settings.grid_trading_enabled and range_like and not dangerous and spread_ok and liquidity_ok and volatility_ok and width > 0 ) buy_zone = bool(active and range_position <= _clamp(settings.grid_buy_zone, 0.05, 0.95)) reason = ( f"диапазон {range_position:.2f}, ширина {range_width_percent:.2f}%" if active else "условия grid-режима не подтверждены" ) return { "enabled": settings.grid_trading_enabled, "active": active, "buy_zone": buy_zone, "range_position": round(range_position, 4), "range_width_percent": round(range_width_percent, 4), "buy_zone_limit": round(_clamp(settings.grid_buy_zone, 0.05, 0.95), 4), "llm_grid_suitable": llm_grid, "range_like": range_like, "dangerous": dangerous, "reason": reason, } def _rebound_state( *, settings: Settings, candles: list[Candle], latest: Candle, previous: Candle, pattern: dict, llm: dict, spread_ok: bool, liquidity_ok: bool, volume_ok: bool, volatility_ok: bool, atr_percent: float, ) -> dict: metrics = pattern.get("metrics") or {} ret_3 = _safe_float( metrics.get("ret_3_percent"), _percent_change(latest.close, candles[-4].close) if len(candles) >= 4 else 0.0, ) ret_10 = _safe_float( metrics.get("ret_10_percent"), _percent_change(latest.close, candles[-11].close) if len(candles) >= 11 else 0.0, ) ret_20 = _safe_float( metrics.get("ret_20_percent"), _percent_change(latest.close, candles[-21].close) if len(candles) >= 21 else 0.0, ) label = str(pattern.get("label") or "").lower() tags = {str(tag).lower() for tag in pattern.get("tags", [])} llm_regime = str(llm.get("market_regime", "")).lower() rsi = _safe_float(latest.rsi_14, 50.0) previous_rsi = _safe_float(previous.rsi_14, rsi) volume_ratio = latest.volume / latest.volume_ma_20 if latest.volume_ma_20 and latest.volume_ma_20 > 0 else 0.0 body = abs(latest.close - latest.open) lower_wick = max(0.0, min(latest.open, latest.close) - latest.low) low6 = min(candle.low for candle in candles[-6:]) high6 = max(candle.high for candle in candles[-6:]) recent_lows = [candle.low for candle in candles[-6:-1]] no_new_low = bool(recent_lows) and latest.low >= min(recent_lows) * 0.999 bounce_from_low = ((latest.close - low6) / latest.close * 100) if latest.close else 0.0 range_width = max(high6 - low6, latest.close * 0.0001) range_position = _clamp((latest.close - low6) / range_width, 0.0, 1.0) recent_drop_depth = max(abs(min(ret_10, 0.0)), abs(min(ret_20, 0.0)) * 0.65) pattern_down = ( label in NEGATIVE_LONG_PATTERNS or any(tag in NEGATIVE_LONG_PATTERNS for tag in tags) or any(marker in label for marker in ("нисход", "пад", "пробой вниз", "ускор")) ) price_drop = ret_10 <= -max(0.35, atr_percent * 1.1) or ret_20 <= -max(0.6, atr_percent * 1.6) recent_drop = bool(price_drop and (pattern_down or ret_10 < 0 or ret_20 < 0)) body_base = max(body, latest.close * 0.0001) wick_absorption = lower_wick >= body_base * 0.6 bounced = bounce_from_low >= max(0.08, atr_percent * 0.3) or range_position >= 0.18 momentum_stabilized = latest.close >= previous.close or abs(ret_3) <= max(0.25, atr_percent * 0.8) or no_new_low rsi_zone = 24 <= rsi <= 52 rsi_improving = rsi >= previous_rsi or rsi <= 38 market_ok = spread_ok and liquidity_ok and volatility_ok continuing_collapse = bool( latest.close < previous.close and not no_new_low and ret_3 <= -max(0.6, atr_percent * 1.2) and rsi < 34 ) panic_regime = llm_regime in {"panic", "breakdown"} drop_score = _clamp(recent_drop_depth / max(0.45, atr_percent * 2.0), 0.0, 1.0) stabilization_score = 1.0 if latest.close >= previous.close else 0.75 if no_new_low else 0.55 if momentum_stabilized else 0.0 absorption_score = _clamp(max(lower_wick / (body_base * 1.4), bounce_from_low / max(0.08, atr_percent * 0.7)), 0.0, 1.0) rsi_score = 1.0 if rsi_zone and rsi_improving else 0.65 if rsi_zone else 0.0 volume_score = _clamp(volume_ratio / 1.2, 0.0, 1.0) market_score = 1.0 if market_ok else 0.0 probability = ( drop_score * 0.22 + stabilization_score * 0.24 + absorption_score * 0.20 + rsi_score * 0.18 + volume_score * 0.08 + market_score * 0.08 ) if continuing_collapse or panic_regime: probability = min(probability, 0.45) if not recent_drop: probability = min(probability, 0.50) if not market_ok: probability = min(probability, 0.55) min_probability = _clamp(settings.rebound_min_probability, 0.45, 0.9) active = bool( settings.rebound_trading_enabled and recent_drop and momentum_stabilized and (wick_absorption or bounced) and rsi_zone and market_ok and volume_ok and not continuing_collapse and not panic_regime and probability >= min_probability ) return { "enabled": settings.rebound_trading_enabled, "active": active, "probability": round(_clamp(probability, 0.0, 1.0), 4), "entry_score": round(_clamp(probability, 0.0, 1.0), 4) if active else 0.0, "min_probability": round(min_probability, 4), "recent_drop": recent_drop, "momentum_stabilized": momentum_stabilized, "wick_absorption": wick_absorption, "bounced_from_low": bounced, "rsi_zone": rsi_zone, "rsi_improving": rsi_improving, "market_ok": market_ok, "volume_ratio": round(volume_ratio, 4), "ret_3_percent": round(ret_3, 4), "ret_10_percent": round(ret_10, 4), "ret_20_percent": round(ret_20, 4), "bounce_from_low_percent": round(bounce_from_low, 4), "range_position_6": round(range_position, 4), "continuing_collapse": continuing_collapse, "panic_regime": panic_regime, "reason": ( "падение замедлилось, есть признаки короткого отскока" if active else "rebound-сигнал не подтвержден" ), } def _safe_float(value: object, default: float = 0.0) -> float: try: return float(value) except (TypeError, ValueError): return default def _percent_change(current: float, previous: float) -> float: return ((current - previous) / previous * 100) if previous else 0.0 def _adaptive_rules(learning: dict | None) -> dict: learning = learning or {} rules = learning.get("adaptive_rules", learning) return dict(rules) if isinstance(rules, dict) else {} def _adaptive_threshold_adjustment(adaptive: dict) -> float: raw = adaptive.get("effective_entry_threshold_adjustment", adaptive.get("entry_threshold_adjustment", 0.0)) return _clamp(_safe_float(raw, 0.0), -0.18, 0.18) def _adaptive_blocks_entry(adaptive: dict, falling_market: bool = False, rebound_confirmed: bool = False) -> bool: if adaptive.get("allow_new_entries") is False: return True if adaptive.get("over_target_exposure"): return True if adaptive.get("symbol_blocked") or adaptive.get("pattern_blocked"): return True if adaptive.get("bad_market_entry_block") and falling_market and not rebound_confirmed: return True return False def _adaptive_block_reason(adaptive: dict, falling_market: bool = False, rebound_confirmed: bool = False) -> str: if adaptive.get("allow_new_entries") is False: return "новые входы выключены режимом обучения" if adaptive.get("over_target_exposure"): return "экспозиция выше цели обучения" if adaptive.get("symbol_blocked"): return "символ в стоп-листе обучения" if adaptive.get("pattern_blocked"): return "шаблон в стоп-листе обучения" if adaptive.get("bad_market_entry_block") and falling_market and not rebound_confirmed: return "падающий рынок, добор запрещен" return "адаптивное правило" def _falling_market(latest: Candle, previous: Candle, pattern_label: str, llm: dict) -> bool: label = pattern_label.lower() llm_regime = str(llm.get("market_regime", "")).lower() ema_down = ( latest.ema_20 is not None and latest.ema_50 is not None and latest.close < latest.ema_50 and latest.ema_20 < latest.ema_50 ) momentum_down = latest.close < previous.close and (latest.rsi_14 is None or latest.rsi_14 < 50) pattern_down = any(marker in label for marker in ("нисход", "пад", "пробой вниз", "ускор")) llm_down = llm_regime in {"downtrend", "breakdown", "panic"} return bool(ema_down or (momentum_down and pattern_down) or llm_down) def _adaptive_percent(adaptive: dict, key: str, default: float, low: float, high: float) -> float: return _clamp(_safe_float(adaptive.get(key), default), low, high) def _estimated_exit_net_percent(position: Position, price: float, settings: Settings) -> float: if position.entry_price <= 0: return 0.0 gross_percent = ((price - position.entry_price) / position.entry_price) * 100 round_trip_cost_percent = (settings.taker_fee_rate * 2 + settings.slippage_rate * 2) * 100 return gross_percent - round_trip_cost_percent def _adaptive_indicator_exit_allowed(adaptive: dict, mode_key: str, estimated_exit_net_percent: float) -> bool: mode = str(adaptive.get(mode_key, "normal")).lower() if mode != "profit_only": return True min_exit_profit = _safe_float(adaptive.get("min_exit_profit_percent"), 0.0) return estimated_exit_net_percent >= min_exit_profit def _forecast_exit_signal( *, forecast: dict, position: Position, price: float, estimated_exit_net_percent: float, stop_loss_percent: float, min_edge_percent: float, ) -> tuple[str, float, str] | None: if not forecast.get("usable"): return None skill = _safe_float(forecast.get("skill"), 0.0) expected_return = _safe_float(forecast.get("expected_return_percent"), 0.0) probability_up = _safe_float(forecast.get("probability_up"), 0.5) min_edge = max(0.0, min_edge_percent) strong_negative = skill > 0.02 and expected_return <= -max(min_edge, 0.03) and probability_up <= 0.44 if not strong_negative: return None reason = forecast.get("reason") or "ожидается снижение" if estimated_exit_net_percent >= 0: return "SELL", 0.82, f"прогноз временного ряда ухудшился: {reason}; фиксируем результат" loss_from_entry = ((price - position.entry_price) / position.entry_price) if position.entry_price else 0.0 soft_loss_limit = -max(0.003, stop_loss_percent * 0.35) if loss_from_entry <= soft_loss_limit: return "SELL", 0.84, f"прогноз временного ряда ухудшился: {reason}; ограничиваем убыток до stop-loss" return None def _learning_blocks_entry( *, learning: dict, learning_adjustment: float, min_samples: int, max_adjustment: float, enabled: bool, ) -> bool: if not enabled: return False sample_size = int(learning.get("sample_size", 0) or 0) net_pnl = float(learning.get("net_pnl", 0.0) or 0.0) win_rate = float(learning.get("win_rate", 0.0) or 0.0) strong_negative_adjustment = -max(0.06, max_adjustment * 0.65) return ( sample_size >= min_samples and net_pnl < 0 and win_rate <= 0.25 and learning_adjustment <= strong_negative_adjustment )