Files
TradeBot/crypto_spot_bot/strategy.py
T

1989 lines
85 KiB
Python

from __future__ import annotations
from dataclasses import replace
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,
account: dict | None = None,
trend_candles: list[Candle] | None = None,
) -> Signal:
if self.settings.strategy_mode == "torch_forecast":
fallback_reasons = torch_model_readiness_reasons(self.settings, forecast or {})
if self.settings.time_series_trend_fallback_enabled and fallback_reasons:
fallback_mode = _effective_fallback_mode(self.settings)
if fallback_mode == "legacy":
fallback_settings = replace(
self.settings,
strategy_mode="legacy",
time_series_forecast_enabled=False,
)
fallback = SpotStrategy(fallback_settings).entry_signal(
symbol,
candles,
ticker,
open_positions_for_symbol,
pattern,
learning,
llm,
{},
account,
trend_candles,
)
trade_mode = "LEGACY_FALLBACK"
entry_path = "legacy_fallback"
else:
fallback = _trend_macd_entry_signal(
settings=self.settings,
symbol=symbol,
candles=candles,
trend_candles=trend_candles or [],
ticker=ticker,
open_positions_for_symbol=open_positions_for_symbol,
account=account,
)
trade_mode = "TREND_MACD_FALLBACK"
entry_path = "trend_macd_fallback"
diagnostics = dict(fallback.diagnostics)
diagnostics.update(
{
"strategy_mode": "torch_forecast",
"trade_mode": trade_mode,
"entry_path": entry_path,
"forecast_fallback_active": True,
"forecast_fallback_reasons": fallback_reasons,
"forecast": forecast or {},
}
)
return Signal(
fallback.symbol,
fallback.action,
fallback.confidence,
f"torch_forecast fallback: {fallback.reason}",
diagnostics,
)
return _torch_forecast_entry_signal(
settings=self.settings,
symbol=symbol,
candles=candles,
ticker=ticker,
open_positions_for_symbol=open_positions_for_symbol,
pattern=pattern or {},
llm=llm or {},
forecast=forecast or {},
account=account,
)
if self.settings.strategy_mode == "trend_macd":
return _trend_macd_entry_signal(
settings=self.settings,
symbol=symbol,
candles=candles,
trend_candles=trend_candles or [],
ticker=ticker,
open_positions_for_symbol=open_positions_for_symbol,
account=account,
)
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_sizing = _position_sizing(
settings=self.settings,
final_score=final_score,
grid_active=grid["active"],
rebound_active=rebound["active"],
forecast=forecast,
adaptive=adaptive,
account=account,
)
position_notional = float(position_sizing["notional_usdt"])
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,
"position_sizing": position_sizing,
"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
adaptive = _adaptive_rules(learning or {})
trailing = position.trailing_stop(self.settings.trailing_stop_percent)
diagnostics = {
"price": price,
"entry_price": position.entry_price,
"stop_loss": position.stop_loss if self.settings.stop_loss_exit_enabled else None,
"stop_loss_exit_enabled": self.settings.stop_loss_exit_enabled,
"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,
"adaptive_rules": adaptive,
}
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)
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 self.settings.strategy_mode == "torch_forecast":
entry_path = str(position.entry_diagnostics.get("entry_path", ""))
if entry_path == "legacy_fallback":
fallback_settings = replace(
self.settings,
strategy_mode="legacy",
time_series_forecast_enabled=False,
)
fallback = SpotStrategy(fallback_settings)._legacy_exit_signal(
position,
candles,
ticker,
learning,
)
diagnostics = dict(fallback.diagnostics)
diagnostics.update(
{
"strategy_mode": "torch_forecast",
"trade_mode": "LEGACY_FALLBACK",
"entry_path": "legacy_fallback",
"forecast_fallback_active": True,
}
)
return Signal(
fallback.symbol,
fallback.action,
fallback.confidence,
f"torch_forecast fallback: {fallback.reason}",
diagnostics,
)
if entry_path == "trend_macd_fallback":
fallback = _trend_macd_exit_signal(self.settings, position, candles, ticker)
diagnostics = dict(fallback.diagnostics)
diagnostics.update(
{
"strategy_mode": "torch_forecast",
"trade_mode": "TREND_MACD_FALLBACK",
"entry_path": "trend_macd_fallback",
"forecast_fallback_active": True,
}
)
return Signal(
fallback.symbol,
fallback.action,
fallback.confidence,
f"torch_forecast fallback: {fallback.reason}",
diagnostics,
)
return _torch_forecast_exit_signal(self.settings, position, candles, ticker, forecast or {})
if self.settings.strategy_mode == "trend_macd":
return _trend_macd_exit_signal(self.settings, position, candles, ticker)
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 = _effective_stop_loss(self.settings, position, 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)
min_exit_net_percent = _min_exit_net_percent(self.settings)
diagnostics = {
"price": price,
"entry_price": position.entry_price,
"stop_loss": effective_stop_loss,
"stop_loss_exit_enabled": self.settings.stop_loss_exit_enabled,
"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_net_percent": min_exit_net_percent,
"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:
diagnostics["emergency_exit"] = True
diagnostics["emergency_exit_type"] = "configured_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,
min_exit_net_percent=min_exit_net_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 _effective_fallback_mode(settings: Settings) -> str:
if settings.trading_mode != "paper":
return "trend_macd"
return settings.time_series_fallback_mode
def _trend_macd_entry_signal(
*,
settings: Settings,
symbol: str,
candles: list[Candle],
trend_candles: list[Candle],
ticker: Ticker | None,
open_positions_for_symbol: int,
account: dict | None,
) -> Signal:
if ticker is None:
return Signal(symbol, "HOLD", 0.0, "нет ticker-данных")
if open_positions_for_symbol > 0:
return Signal(symbol, "HOLD", 0.0, "позиция по паре уже открыта")
if len(candles) < 60:
return Signal(symbol, "HOLD", 0.0, "недостаточно 1h свечей для trend_macd")
if len(trend_candles) < 200:
return Signal(symbol, "HOLD", 0.0, "недостаточно 1d свечей для EMA200")
latest = candles[-1]
previous = candles[-2]
trend_latest = trend_candles[-1]
if not _has_trend_entry_indicators(latest, previous, trend_latest):
return Signal(symbol, "HOLD", 0.0, "индикаторы trend_macd еще не готовы")
spread_ok = ticker.spread_percent <= settings.max_spread_percent
liquidity_ok = ticker.turnover_24h >= settings.min_24h_turnover_usdt
daily_trend_ok = bool(trend_latest.close > trend_latest.ema_200 and trend_latest.ema_50 > trend_latest.ema_200)
macd_cross_up = _macd_crossed_up(previous, latest)
price_above_ema50 = bool(latest.close > latest.ema_50)
rsi_min = min(settings.trend_rsi_min, settings.trend_rsi_max)
rsi_max = max(settings.trend_rsi_min, settings.trend_rsi_max)
rsi_ok = bool(rsi_min <= latest.rsi_14 <= rsi_max)
stop_loss_percent = _clamp(settings.stop_loss_percent, 0.003, 0.08)
sizing = _trend_position_sizing(settings, account, stop_loss_percent)
position_notional = float(sizing["notional_usdt"])
checks = {
"spread_ok": spread_ok,
"liquidity_ok": liquidity_ok,
"daily_trend_ok": daily_trend_ok,
"macd_cross_up": macd_cross_up,
"price_above_ema50": price_above_ema50,
"rsi_ok": rsi_ok,
"risk_size_ok": position_notional >= settings.min_position_usdt,
}
diagnostics = {
"strategy_mode": "trend_macd",
"trade_mode": "TREND_MACD",
"position_notional_usdt": position_notional,
"position_sizing": sizing,
"stop_loss_percent": stop_loss_percent,
"atr_trailing_multiplier": _clamp(settings.atr_trailing_multiplier, 0.5, 10.0),
"entry_timeframe": settings.base_interval,
"trend_timeframe": settings.trend_interval,
"rsi_14": latest.rsi_14,
"rsi_min": rsi_min,
"rsi_max": rsi_max,
"ema_50": latest.ema_50,
"macd": latest.macd,
"macd_signal": latest.macd_signal,
"trend_close": trend_latest.close,
"trend_ema_50": trend_latest.ema_50,
"trend_ema_200": trend_latest.ema_200,
"spread_percent": round(ticker.spread_percent, 5),
"turnover_24h": ticker.turnover_24h,
"checks": checks,
"grid": {"enabled": False, "active": False},
"rebound": {"enabled": False, "active": False},
"forecast": {},
"learning": {},
"llm": {},
}
if all(checks.values()):
return Signal(
symbol,
"BUY",
0.86,
f"trend_macd: 1d тренд вверх, MACD пересек signal вверх, RSI {latest.rsi_14:.1f}, размер {position_notional:.2f} USDT",
diagnostics,
)
failed = ", ".join(name for name, ok in checks.items() if not ok)
return Signal(symbol, "HOLD", 0.35, f"trend_macd: условия входа не выполнены ({failed})", diagnostics)
def _trend_macd_exit_signal(
settings: Settings,
position: Position,
candles: list[Candle],
ticker: Ticker | None,
) -> Signal:
if ticker is None:
return Signal(position.symbol, "HOLD", 0.0, "нет ticker-данных для выхода")
if len(candles) < 2:
return Signal(position.symbol, "HOLD", 0.0, "недостаточно 1h свечей для выхода")
latest = candles[-1]
previous = candles[-2]
price = ticker.last_price
stop_loss_percent = _clamp(settings.stop_loss_percent, 0.003, 0.08)
effective_stop_loss = _effective_stop_loss(settings, position, stop_loss_percent)
atr_multiplier = _clamp(settings.atr_trailing_multiplier, 0.5, 10.0)
atr_trailing_stop = _atr_trailing_stop(settings, position, latest.atr_14, atr_multiplier, effective_stop_loss)
macd_cross_down = _macd_crossed_down(previous, latest)
close_below_ema50 = latest.ema_50 is not None and latest.close < latest.ema_50
diagnostics = {
"strategy_mode": "trend_macd",
"price": price,
"entry_price": position.entry_price,
"stop_loss": effective_stop_loss,
"stop_loss_exit_enabled": settings.stop_loss_exit_enabled,
"atr_trailing_stop": atr_trailing_stop,
"atr_trailing_multiplier": atr_multiplier,
"highest_price": position.highest_price,
"ema_50": latest.ema_50,
"rsi_14": latest.rsi_14,
"atr_14": latest.atr_14,
"macd": latest.macd,
"macd_signal": latest.macd_signal,
"macd_cross_down": macd_cross_down,
"close_below_ema50": close_below_ema50,
}
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)
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)
if macd_cross_down:
return Signal(position.symbol, "SELL", 0.84, "trend_macd: MACD пересек signal вниз", diagnostics)
if close_below_ema50:
return Signal(position.symbol, "SELL", 0.82, "trend_macd: 1h свеча закрылась ниже EMA50", diagnostics)
return Signal(position.symbol, "HOLD", 0.35, "trend_macd: условия выхода не выполнены", diagnostics)
def _torch_forecast_entry_signal(
*,
settings: Settings,
symbol: str,
candles: list[Candle] | None,
ticker: Ticker | None,
open_positions_for_symbol: int,
pattern: dict,
llm: dict,
forecast: dict,
account: dict | None,
) -> Signal:
if ticker is None:
return Signal(symbol, "HOLD", 0.0, "torch_forecast: no ticker data")
if open_positions_for_symbol >= _dynamic_symbol_position_limit(settings):
return Signal(symbol, "HOLD", 0.0, "torch_forecast: symbol position limit reached")
account_context = dict(account or {})
account_context.setdefault("symbol", symbol)
account_context.setdefault("open_positions_for_symbol", open_positions_for_symbol)
stop_loss_percent = _clamp(settings.stop_loss_percent, 0.003, 0.08)
sizing = _torch_forecast_position_sizing(settings, account_context, stop_loss_percent, forecast, symbol)
position_notional = float(sizing["notional_usdt"])
expected_return = _safe_float(forecast.get("expected_return_percent"), 0.0)
probability_up = _forecast_probability(forecast)
skill = _safe_float(forecast.get("skill"), 0.0)
min_edge = max(0.0, _safe_float(forecast.get("calibrated_min_edge_percent"), settings.time_series_min_edge_percent))
min_probability = _clamp(
_safe_float(forecast.get("calibrated_min_probability_up"), _torch_min_probability(settings)),
0.5,
0.95,
)
probe_min_edge = max(0.0, min(settings.time_series_probe_min_edge_percent, min_edge))
probe_min_probability = round(
_clamp(settings.time_series_probe_min_probability_up, min_probability, 0.85),
4,
)
full_edge_ok = expected_return >= min_edge
probe_edge_ok = bool(
settings.time_series_probe_enabled
and not full_edge_ok
and expected_return >= probe_min_edge
and probability_up >= probe_min_probability
)
edge_mode = "full" if full_edge_ok else ("probe" if probe_edge_ok else "blocked")
if probe_edge_ok and position_notional > 0:
probe_multiplier = _clamp(settings.time_series_probe_size_multiplier, 0.05, 1.0)
position_notional = round(
min(
settings.max_position_usdt,
max(settings.min_position_usdt, position_notional * probe_multiplier),
),
2,
)
sizing = {
**sizing,
"notional_usdt": position_notional,
"probe_size_multiplier": round(probe_multiplier, 4),
"edge_mode": "probe",
}
confidence = _torch_forecast_confidence(settings, forecast)
spread_ok = ticker.spread_percent <= settings.max_spread_percent
liquidity_ok = ticker.turnover_24h >= settings.min_24h_turnover_usdt
model_ok = _is_torch_forecast(forecast)
manual_quality_override = settings.time_series_manual_quality_override
quality_gate_ok = bool(
manual_quality_override
or (
forecast.get("quality_gate_passed") is True
if settings.time_series_require_quality_gate
else forecast.get("quality_gate_passed") is not False
)
)
model_fresh_ok = (
forecast.get("model_fresh") is True
if settings.time_series_require_fresh_model
else True
)
rebound = _torch_rebound_overlay(
settings=settings,
candles=candles or [],
ticker=ticker,
pattern=pattern,
llm=llm,
spread_ok=spread_ok,
liquidity_ok=liquidity_ok,
)
rebound_model_probability_min = round(
_clamp(settings.time_series_probe_min_probability_up, 0.50, 0.75),
4,
)
missing_torch_model = _missing_torch_model(forecast)
model_rebound_entry_ok = bool(
rebound.get("active")
and model_ok
and quality_gate_ok
and model_fresh_ok
and bool(forecast.get("usable", False))
and not bool(forecast.get("block_entry", False))
and expected_return >= 0.0
and probability_up >= rebound_model_probability_min
and skill > 0.0
and confidence >= _safe_float(forecast.get("calibrated_min_confidence"), settings.time_series_min_confidence)
)
fallback_rebound_entry_ok = bool(
settings.time_series_rebound_fallback_enabled
and rebound.get("active")
and missing_torch_model
and quality_gate_ok
and model_fresh_ok
and not bool(forecast.get("block_entry", False))
and confidence >= _safe_float(forecast.get("calibrated_min_confidence"), settings.time_series_min_confidence)
)
rebound_entry_ok = model_rebound_entry_ok or fallback_rebound_entry_ok
if rebound_entry_ok and position_notional > 0:
rebound_cap = max(settings.min_position_usdt, settings.rebound_max_position_usdt)
position_notional = round(
min(settings.max_position_usdt, rebound_cap, max(settings.min_position_usdt, position_notional)),
2,
)
sizing_method = "torch_forecast_rebound_fallback" if fallback_rebound_entry_ok else "torch_forecast_rebound"
sizing = {
**sizing,
"method": sizing_method,
"notional_usdt": position_notional,
"edge_mode": "rebound_fallback" if fallback_rebound_entry_ok else "rebound",
"rebound_probability": rebound.get("probability", 0.0),
}
edge_mode = "rebound_fallback" if fallback_rebound_entry_ok else "rebound"
risk_size_ok = position_notional >= settings.min_position_usdt
rebound_entry_sized_ok = rebound_entry_ok and risk_size_ok
checks = {
"torch_model_ok": model_ok,
"quality_gate_ok": quality_gate_ok,
"model_fresh_ok": model_fresh_ok,
"forecast_usable": bool(forecast.get("usable", False)),
"forecast_not_blocked": not bool(forecast.get("block_entry", False)),
"expected_edge_ok": full_edge_ok or probe_edge_ok,
"probability_ok": probability_up >= min_probability,
"skill_ok": skill > 0.0,
"confidence_ok": confidence >= settings.time_series_min_confidence,
"spread_ok": spread_ok,
"liquidity_ok": liquidity_ok,
"risk_size_ok": risk_size_ok,
}
diagnostics = {
"strategy_mode": "torch_forecast",
"trade_mode": "TORCH_FORECAST",
"forecast": forecast,
"position_notional_usdt": position_notional,
"position_sizing": sizing,
"stop_loss_percent": stop_loss_percent,
"atr_trailing_multiplier": _clamp(settings.atr_trailing_multiplier, 0.5, 10.0),
"expected_return_percent": expected_return,
"min_edge_percent": min_edge,
"probe_enabled": settings.time_series_probe_enabled,
"probe_min_edge_percent": probe_min_edge,
"probe_min_probability_up": probe_min_probability,
"edge_mode": edge_mode,
"probability_up": probability_up,
"min_probability_up": min_probability,
"rebound_model_probability_min": rebound_model_probability_min,
"missing_torch_model": missing_torch_model,
"time_series_rebound_fallback_enabled": settings.time_series_rebound_fallback_enabled,
"model_rebound_entry_ok": model_rebound_entry_ok,
"fallback_rebound_entry_ok": fallback_rebound_entry_ok,
"rebound_entry_ok": rebound_entry_ok,
"rebound_entry_sized_ok": rebound_entry_sized_ok,
"min_confidence": settings.time_series_min_confidence,
"skill": skill,
"quality_gate": forecast.get("quality_gate", {}),
"quality_gate_passed": forecast.get("quality_gate_passed"),
"manual_quality_override": manual_quality_override,
"model_created_at": forecast.get("model_created_at", ""),
"model_age_hours": forecast.get("model_age_hours"),
"model_fresh": forecast.get("model_fresh", False),
"spread_percent": round(ticker.spread_percent, 5),
"turnover_24h": ticker.turnover_24h,
"checks": checks,
"grid": {"enabled": False, "active": False},
"rebound": rebound,
"learning": {},
"llm": {},
}
base_entry_ok = all(checks.values())
if base_entry_ok or rebound_entry_sized_ok:
buy_confidence = max(confidence, float(rebound.get("probability", 0.0) or 0.0)) if rebound_entry_sized_ok else confidence
entry_path = edge_mode if rebound_entry_ok and not base_entry_ok else edge_mode
diagnostics["entry_path"] = entry_path
if fallback_rebound_entry_ok and not base_entry_ok:
reason = (
"torch_forecast: rebound fallback confirmed without PyTorch model; "
f"rebound_probability={float(rebound.get('probability', 0.0) or 0.0):.3f}, "
f"size={position_notional:.2f} USDT"
)
elif rebound_entry_ok and not base_entry_ok:
reason = (
"torch_forecast: rebound overlay confirmed; "
f"model={forecast.get('model')}, p_up={probability_up:.3f}, "
f"expected={expected_return:.4f}%, rebound_probability={float(rebound.get('probability', 0.0) or 0.0):.3f}, "
f"size={position_notional:.2f} USDT"
)
else:
reason = (
"torch_forecast: PyTorch edge confirmed; "
f"model={forecast.get('model')}, p_up={probability_up:.3f}, "
f"expected={expected_return:.4f}%, edge_mode={edge_mode}, "
f"size={position_notional:.2f} USDT"
)
return Signal(
symbol,
"BUY",
round(_clamp(buy_confidence, 0.0, 0.96), 4),
reason,
diagnostics,
)
failed = ", ".join(name for name, ok in checks.items() if not ok)
return Signal(symbol, "HOLD", confidence, f"torch_forecast: entry blocked ({failed})", diagnostics)
def _torch_rebound_overlay(
*,
settings: Settings,
candles: list[Candle],
ticker: Ticker,
pattern: dict,
llm: dict,
spread_ok: bool,
liquidity_ok: bool,
) -> dict:
if not settings.rebound_trading_enabled:
return {"enabled": False, "active": False, "reason": "rebound trading disabled"}
if len(candles) < 21:
return {"enabled": True, "active": False, "reason": "not enough candles for rebound"}
latest = candles[-1]
previous = candles[-2] if len(candles) >= 2 else latest
if not _has_entry_indicators(latest):
return {"enabled": True, "active": False, "reason": "entry indicators are not ready"}
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 and latest.atr_14 is not None else 0.0
volatility_ok = 0.04 <= atr_percent <= 6.0
return _rebound_state(
settings=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,
)
def _torch_forecast_exit_signal(
settings: Settings,
position: Position,
candles: list[Candle],
ticker: Ticker | None,
forecast: dict,
) -> Signal:
if ticker is None:
return Signal(position.symbol, "HOLD", 0.0, "torch_forecast: no ticker data for exit")
latest = candles[-1] if candles else None
price = ticker.last_price
stop_loss_percent = _clamp(settings.stop_loss_percent, 0.003, 0.08)
effective_stop_loss = _effective_stop_loss(settings, position, stop_loss_percent)
atr_multiplier = _clamp(settings.atr_trailing_multiplier, 0.5, 10.0)
atr_trailing_stop = _atr_trailing_stop(
settings,
position,
latest.atr_14 if latest else None,
atr_multiplier,
effective_stop_loss,
)
expected_return = _safe_float(forecast.get("expected_return_percent"), 0.0)
probability_up = _forecast_probability(forecast)
skill = _safe_float(forecast.get("skill"), 0.0)
min_edge = max(0.0, _safe_float(forecast.get("calibrated_min_edge_percent"), settings.time_series_min_edge_percent))
min_probability = _clamp(
_safe_float(forecast.get("calibrated_min_probability_up"), _torch_min_probability(settings)),
0.5,
0.95,
)
estimated_exit_net_percent = _estimated_exit_net_percent(position, price, settings)
min_exit_net_percent = _min_exit_net_percent(settings)
entry_path = str(position.entry_diagnostics.get("entry_path", ""))
entry_edge_mode = str(position.entry_diagnostics.get("edge_mode", ""))
rebound_fallback_position = entry_path == "rebound_fallback" or entry_edge_mode == "rebound_fallback"
diagnostics = {
"strategy_mode": "torch_forecast",
"price": price,
"entry_price": position.entry_price,
"stop_loss": effective_stop_loss,
"stop_loss_exit_enabled": settings.stop_loss_exit_enabled,
"take_profit": position.take_profit,
"atr_trailing_stop": atr_trailing_stop,
"atr_trailing_multiplier": atr_multiplier,
"highest_price": position.highest_price,
"entry_path": entry_path,
"entry_edge_mode": entry_edge_mode,
"rebound_fallback_position": rebound_fallback_position,
"forecast": forecast,
"expected_return_percent": expected_return,
"min_edge_percent": min_edge,
"probability_up": probability_up,
"min_probability_up": min_probability,
"skill": skill,
"estimated_exit_net_percent": round(estimated_exit_net_percent, 4),
"min_exit_net_percent": min_exit_net_percent,
"atr_14": latest.atr_14 if latest else None,
}
hold_seconds = (utc_now() - position.opened_at).total_seconds()
diagnostics["hold_seconds"] = hold_seconds
diagnostics["min_hold_seconds"] = settings.min_hold_seconds
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)
if price >= position.take_profit:
return Signal(position.symbol, "SELL", 0.96, "torch_forecast: take-profit hit", diagnostics)
if atr_trailing_stop is not None and price <= atr_trailing_stop:
if estimated_exit_net_percent < min_exit_net_percent:
diagnostics["atr_exit_blocked_by_min_profit"] = True
if estimated_exit_net_percent < 0:
diagnostics["atr_exit_blocked_by_cost"] = True
return Signal(
position.symbol,
"HOLD",
0.45,
"torch_forecast: ATR trailing touched, but exit profit is below minimum",
diagnostics,
)
return Signal(position.symbol, "SELL", 0.94, "torch_forecast: ATR trailing stop hit", diagnostics)
if (
settings.time_series_require_quality_gate
and not settings.time_series_manual_quality_override
and forecast.get("quality_gate_passed") is not True
):
diagnostics["forecast_exit_blocked_by_quality_gate"] = True
return Signal(
position.symbol,
"HOLD",
0.42,
"torch_forecast: hold uses only risk exits while quality gate is unavailable",
diagnostics,
)
if settings.time_series_require_fresh_model and forecast.get("model_fresh") is not True:
diagnostics["forecast_exit_blocked_by_model_age"] = True
return Signal(
position.symbol,
"HOLD",
0.42,
"torch_forecast: hold uses only risk exits while model is stale",
diagnostics,
)
if not _is_torch_forecast(forecast):
if rebound_fallback_position:
hold_seconds = (utc_now() - position.opened_at).total_seconds()
diagnostics["hold_seconds"] = hold_seconds
if hold_seconds < settings.min_hold_seconds:
return Signal(
position.symbol,
"HOLD",
0.45,
"torch_forecast: rebound fallback minimum hold",
diagnostics,
)
return Signal(
position.symbol,
"HOLD",
0.42,
"torch_forecast: rebound fallback hold without PyTorch model",
diagnostics,
)
return Signal(position.symbol, "SELL", 0.78, "torch_forecast: no valid PyTorch forecast to hold", diagnostics)
if bool(forecast.get("block_entry", False)) or expected_return <= 0.0 or probability_up <= 0.50:
if hold_seconds < settings.min_hold_seconds:
diagnostics["forecast_exit_blocked_by_min_hold"] = True
return Signal(
position.symbol,
"HOLD",
0.46,
"torch_forecast: minimum hold protects against fee churn",
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=min_edge,
min_exit_net_percent=min_exit_net_percent,
)
if forecast_exit is not None:
action, confidence, reason = forecast_exit
return Signal(position.symbol, action, confidence, reason, diagnostics)
diagnostics["forecast_exit_blocked_by_min_profit"] = True
if estimated_exit_net_percent < 0:
diagnostics["forecast_exit_blocked_by_cost"] = True
return Signal(
position.symbol,
"HOLD",
0.44,
(
"torch_forecast: forecast weakened, but exit profit is below minimum; "
f"p_up={probability_up:.3f}, expected={expected_return:.4f}%"
),
diagnostics,
)
weak_hold = expected_return < min_edge or probability_up < min_probability or skill <= 0.0
if weak_hold and estimated_exit_net_percent >= min_exit_net_percent:
return Signal(
position.symbol,
"SELL",
0.74,
(
"torch_forecast: PyTorch no longer confirms enough edge; "
f"p_up={probability_up:.3f}, expected={expected_return:.4f}%"
),
diagnostics,
)
if weak_hold and estimated_exit_net_percent >= 0:
diagnostics["weak_exit_blocked_by_min_profit"] = True
return Signal(position.symbol, "HOLD", 0.35, "torch_forecast: PyTorch hold confirmed", diagnostics)
def _is_torch_forecast(forecast: dict) -> bool:
model = str(forecast.get("model", "")).strip().lower()
return bool(forecast.get("usable", False)) and model in {"torch_lstm", "torch_gru"}
def torch_model_readiness_reasons(settings: Settings, forecast: dict) -> list[str]:
reasons: list[str] = []
if not _is_torch_forecast(forecast):
reasons.append("torch_model_unavailable")
if (
settings.time_series_require_quality_gate
and not settings.time_series_manual_quality_override
and forecast.get("quality_gate_passed") is not True
):
reasons.append("quality_gate_not_passed")
if settings.time_series_require_fresh_model and forecast.get("model_fresh") is not True:
reasons.append("model_not_fresh")
return reasons
def _missing_torch_model(forecast: dict) -> bool:
model = str(forecast.get("model", "")).strip().lower()
reason = str(forecast.get("reason", "")).lower()
return (
not bool(forecast.get("usable", False))
and model in {"", "none"}
and "no valid pytorch" in reason
)
def _torch_min_probability(settings: Settings) -> float:
return round(_clamp(settings.time_series_min_probability_up, 0.45, 0.75), 4)
def _dynamic_symbol_position_limit(settings: Settings) -> int:
configured_limit = max(1, settings.max_positions_per_symbol)
exposure_based_limit = max(
1,
int(settings.max_symbol_exposure_usdt // max(settings.min_position_usdt, 0.01)),
)
return min(configured_limit, exposure_based_limit)
def _torch_forecast_confidence(settings: Settings, forecast: dict) -> float:
expected_return = max(0.0, _safe_float(forecast.get("expected_return_percent"), 0.0))
probability_up = _forecast_probability(forecast)
skill = max(0.0, _safe_float(forecast.get("skill"), 0.0))
min_edge = max(0.01, settings.time_series_min_edge_percent)
edge_strength = _clamp(expected_return / max(min_edge * 4.0, 0.01), 0.0, 1.0)
probability_strength = _clamp((probability_up - 0.50) / 0.25, 0.0, 1.0)
skill_strength = _clamp(skill / 0.35, 0.0, 1.0)
confidence = 0.45 + probability_strength * 0.30 + edge_strength * 0.20 + skill_strength * 0.10
return round(_clamp(confidence, 0.0, 0.96), 4)
def _torch_forecast_position_sizing(
settings: Settings,
account: dict | None,
stop_loss_percent: float,
forecast: dict,
symbol: str | None = None,
) -> dict[str, float | str]:
base = _trend_position_sizing(settings, account, stop_loss_percent)
base_notional = float(base["notional_usdt"])
kelly = _kelly_position(
settings=settings,
final_score=_torch_forecast_confidence(settings, forecast),
forecast=forecast,
adaptive={},
account=account,
symbol=symbol,
)
expected_return = max(0.0, _safe_float(forecast.get("expected_return_percent"), 0.0))
probability_up = _forecast_probability(forecast)
skill = max(0.0, _safe_float(forecast.get("skill"), 0.0))
min_edge = max(0.01, settings.time_series_min_edge_percent)
edge_multiplier = _clamp(expected_return / max(min_edge * 3.0, 0.01), 0.25, 1.15)
probability_multiplier = _clamp(0.75 + (probability_up - 0.55) * 3.0, 0.50, 1.20)
skill_multiplier = _clamp(0.85 + skill * 0.60, 0.60, 1.15)
if settings.kelly_sizing_enabled:
raw = float(kelly["kelly_remaining_notional_usdt"]) * _risk_guard_multiplier(account)
notional = 0.0 if raw < settings.min_position_usdt else min(raw, settings.max_position_usdt)
elif base_notional <= 0:
notional = 0.0
else:
raw = base_notional * edge_multiplier * probability_multiplier * skill_multiplier
notional = 0.0 if raw < settings.min_position_usdt else min(raw, settings.max_position_usdt)
return {
**base,
"method": "torch_forecast_fractional_kelly" if settings.kelly_sizing_enabled else "torch_forecast_risk",
"enabled": bool(settings.kelly_sizing_enabled),
"notional_usdt": round(notional, 2),
"base_notional_usdt": base["notional_usdt"],
"torch_edge_multiplier": round(edge_multiplier, 4),
"torch_probability_multiplier": round(probability_multiplier, 4),
"torch_skill_multiplier": round(skill_multiplier, 4),
**kelly,
}
def _has_trend_entry_indicators(current: Candle, previous: Candle, trend: Candle) -> bool:
return all(
value is not None
for value in (
current.ema_50,
current.rsi_14,
current.atr_14,
current.macd,
current.macd_signal,
previous.macd,
previous.macd_signal,
trend.ema_50,
trend.ema_200,
)
)
def _macd_crossed_up(previous: Candle, current: Candle) -> bool:
if None in (previous.macd, previous.macd_signal, current.macd, current.macd_signal):
return False
return bool(previous.macd <= previous.macd_signal and current.macd > current.macd_signal)
def _macd_crossed_down(previous: Candle, current: Candle) -> bool:
if None in (previous.macd, previous.macd_signal, current.macd, current.macd_signal):
return False
return bool(previous.macd >= previous.macd_signal and current.macd < current.macd_signal)
def _trend_position_sizing(
settings: Settings,
account: dict | None,
stop_loss_percent: float,
) -> dict[str, float | str]:
equity = _safe_float((account or {}).get("equity"), settings.starting_balance_usdt)
if equity <= 0:
equity = settings.starting_balance_usdt
risk_fraction = _clamp(settings.risk_per_trade_percent, 0.0, 0.01)
guard_multiplier = _risk_guard_multiplier(account)
risk_fraction *= guard_multiplier
risk_usdt = equity * risk_fraction
raw_notional = risk_usdt / max(stop_loss_percent, 0.0001)
high = max(0.0, settings.max_position_usdt)
low = max(0.0, settings.min_position_usdt)
notional = 0.0 if raw_notional < low else min(raw_notional, high)
return {
"method": "fixed_fractional_risk",
"risk_per_trade_percent": round(risk_fraction * 100, 4),
"risk_guard_multiplier": round(guard_multiplier, 4),
"risk_usdt": round(risk_usdt, 4),
"stop_loss_percent": round(stop_loss_percent * 100, 4),
"raw_notional_usdt": round(raw_notional, 4),
"notional_usdt": round(notional, 2),
"equity_usdt": round(equity, 2),
}
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_sizing(
*,
settings: Settings,
final_score: float,
grid_active: bool,
rebound_active: bool,
forecast: dict | None = None,
adaptive: dict | None = None,
account: dict | None = None,
) -> dict[str, float | bool | str]:
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)
confidence_notional = low + (high - low) * confidence_ratio
risk_multiplier = _position_risk_multiplier(forecast, adaptive) * _risk_guard_multiplier(account)
method = "confidence"
raw = confidence_notional
kelly = _kelly_position(
settings=settings,
final_score=final_score,
forecast=forecast or {},
adaptive=adaptive or {},
account=account,
)
if settings.kelly_sizing_enabled:
method = "fractional_kelly"
raw = float(kelly["kelly_notional_usdt"])
raw *= risk_multiplier
notional = round(_clamp(raw, low, high), 2)
return {
"method": method,
"enabled": bool(settings.kelly_sizing_enabled),
"notional_usdt": notional,
"confidence_notional_usdt": round(confidence_notional, 2),
"risk_multiplier": round(risk_multiplier, 4),
"low_cap_usdt": round(low, 2),
"high_cap_usdt": round(high, 2),
**kelly,
}
def _position_risk_multiplier(forecast: dict | None, adaptive: dict | None) -> float:
multiplier = 1.0
forecast = forecast or {}
if forecast.get("usable"):
probability_up = _forecast_probability(forecast)
volatility_percent = _safe_float(forecast.get("volatility_percent"), 0.0)
if probability_up < 0.52:
multiplier *= 0.75
elif probability_up >= 0.60:
multiplier *= 1.08
if volatility_percent >= 0.8:
multiplier *= 0.70
learning_multiplier = _safe_float((adaptive or {}).get("effective_position_size_multiplier"), 1.0)
multiplier *= _clamp(learning_multiplier, 0.25, 2.0)
return multiplier
def _risk_guard_multiplier(account: dict | None) -> float:
guard = (account or {}).get("risk_guard")
if not isinstance(guard, dict):
return 1.0
try:
value = float(guard.get("position_size_multiplier", 1.0))
except (TypeError, ValueError):
value = 1.0
return _clamp(value, 0.0, 1.0)
def _kelly_position(
*,
settings: Settings,
final_score: float,
forecast: dict,
adaptive: dict,
account: dict | None,
symbol: str | None = None,
) -> dict[str, float | bool | str]:
confidence_probability = _confidence_probability(final_score, settings.min_signal_confidence)
probability_source = "confidence"
probability = confidence_probability
if forecast.get("usable"):
probability = _forecast_probability(forecast, confidence_probability)
probability_source = "forecast"
probability = _clamp(probability, 0.0, 1.0)
stop_loss = _adaptive_percent(adaptive, "stop_loss_percent", settings.stop_loss_percent, 0.003, 0.08)
take_profit = _adaptive_percent(adaptive, "take_profit_percent", settings.take_profit_percent, 0.003, 0.20)
round_trip_cost = max(0.0, 2.0 * (settings.taker_fee_rate + settings.slippage_rate))
base_win_return = max(0.0, take_profit - round_trip_cost)
loss_return = max(0.0001, stop_loss + round_trip_cost)
expected_net_return = max(0.0, _safe_float(forecast.get("expected_return_percent"), 0.0) / 100.0)
implied_win_return = 0.0
if probability > 0:
implied_win_return = max(0.0, (expected_net_return + (1.0 - probability) * loss_return) / probability)
win_return = max(base_win_return, implied_win_return)
reward_loss_ratio = win_return / loss_return if loss_return > 0 else 0.0
full_kelly = probability - ((1.0 - probability) / reward_loss_ratio) if reward_loss_ratio > 0 else 0.0
full_kelly = max(0.0, full_kelly)
fractional_kelly = full_kelly * _clamp(settings.kelly_fraction, 0.0, 1.0)
effective_fraction = _clamp(fractional_kelly, 0.0, _clamp(settings.kelly_max_fraction, 0.0, 1.0))
bankroll = _safe_float((account or {}).get("equity"), settings.starting_balance_usdt)
if bankroll <= 0:
bankroll = settings.starting_balance_usdt
target_notional = max(0.0, bankroll * effective_fraction)
open_symbol_exposure = _account_symbol_exposure(account, symbol)
raw_remaining_notional = max(0.0, target_notional - open_symbol_exposure)
exchange_min_entry = _account_exchange_min_entry(account, settings)
remaining_notional = raw_remaining_notional
effective_target_notional = target_notional
layer_mode = False
if (
symbol
and target_notional > 0
and raw_remaining_notional < exchange_min_entry
and exchange_min_entry > settings.min_position_usdt + 1e-9
and _account_open_positions_for_symbol(account) > 0
):
room = min(
max(0.0, settings.max_position_usdt),
max(0.0, settings.max_symbol_exposure_usdt - open_symbol_exposure),
max(0.0, settings.max_total_exposure_usdt - _account_total_exposure(account)),
max(0.0, _safe_float((account or {}).get("cash"), settings.starting_balance_usdt) - settings.min_cash_reserve_usdt),
)
if room >= exchange_min_entry:
remaining_notional = exchange_min_entry
effective_target_notional = open_symbol_exposure + exchange_min_entry
layer_mode = True
return {
"kelly_probability": round(probability, 4),
"kelly_probability_source": probability_source,
"kelly_reward_loss_ratio": round(reward_loss_ratio, 4),
"kelly_win_return_percent": round(win_return * 100.0, 4),
"kelly_loss_return_percent": round(loss_return * 100.0, 4),
"kelly_expected_net_percent": round(expected_net_return * 100.0, 4),
"kelly_full_fraction": round(full_kelly, 4),
"kelly_fractional_fraction": round(fractional_kelly, 4),
"kelly_effective_fraction": round(effective_fraction, 4),
"kelly_bankroll_usdt": round(bankroll, 2),
"kelly_target_notional_usdt": round(target_notional, 2),
"kelly_effective_target_notional_usdt": round(effective_target_notional, 2),
"kelly_open_symbol_exposure_usdt": round(open_symbol_exposure, 2),
"kelly_raw_remaining_notional_usdt": round(raw_remaining_notional, 2),
"kelly_remaining_notional_usdt": round(remaining_notional, 2),
"kelly_notional_usdt": round(remaining_notional, 2),
"kelly_exchange_min_entry_usdt": round(exchange_min_entry, 2),
"kelly_layer_mode": layer_mode,
}
def _account_symbol_exposure(account: dict | None, symbol: str | None = None) -> float:
if not isinstance(account, dict):
return 0.0
direct = _safe_float(account.get("symbol_exposure_usdt"), -1.0)
if direct >= 0:
return max(0.0, direct)
if not symbol:
symbol = str(account.get("symbol", "") or "")
exposures = account.get("symbol_exposures")
if isinstance(exposures, dict) and symbol:
return max(0.0, _safe_float(exposures.get(symbol), 0.0))
return 0.0
def _account_total_exposure(account: dict | None) -> float:
if not isinstance(account, dict):
return 0.0
return max(0.0, _safe_float(account.get("exposure"), 0.0))
def _account_open_positions_for_symbol(account: dict | None) -> int:
if not isinstance(account, dict):
return 0
try:
return max(0, int(account.get("open_positions_for_symbol", 0)))
except (TypeError, ValueError):
return 0
def _account_exchange_min_entry(account: dict | None, settings: Settings) -> float:
minimum = max(0.0, settings.min_position_usdt)
if not isinstance(account, dict):
return minimum
return max(minimum, _safe_float(account.get("exchange_min_entry_usdt"), minimum))
def _confidence_probability(final_score: float, min_signal_confidence: float) -> float:
denominator = max(0.0001, 1.0 - min_signal_confidence)
ratio = _clamp((final_score - min_signal_confidence) / denominator, 0.0, 1.0)
return 0.50 + ratio * 0.18
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 _forecast_probability(forecast: dict, default: float = 0.5) -> float:
value = forecast.get("probability_take_profit_first")
if not isinstance(value, (int, float, str)):
value = forecast.get("probability_up")
return _clamp(_safe_float(value, default), 0.0, 1.0)
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 _effective_stop_loss(settings: Settings, position: Position, stop_loss_percent: float) -> float | None:
if not settings.stop_loss_exit_enabled:
return None
return max(position.stop_loss, position.entry_price * (1 - stop_loss_percent))
def _atr_trailing_stop(
settings: Settings,
position: Position,
atr: float | None,
atr_multiplier: float,
effective_stop_loss: float | None,
) -> float | None:
if atr is None or atr <= 0 or position.highest_price <= position.entry_price:
return None
raw_stop = position.highest_price - atr * atr_multiplier
if settings.stop_loss_exit_enabled and effective_stop_loss is not None:
return max(effective_stop_loss, raw_stop)
return raw_stop if raw_stop > position.entry_price else None
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 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:
return round(_clamp(settings.min_exit_net_percent, 0.0, 5.0), 4)
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,
min_exit_net_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 = _forecast_probability(forecast)
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 >= min_exit_net_percent:
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
)