1692 lines
73 KiB
Python
1692 lines
73 KiB
Python
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,
|
|
account: dict | None = None,
|
|
trend_candles: list[Candle] | None = None,
|
|
) -> Signal:
|
|
if self.settings.strategy_mode == "torch_forecast":
|
|
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
|
|
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,
|
|
}
|
|
if self.settings.stop_loss_exit_enabled and 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 self.settings.strategy_mode == "torch_forecast":
|
|
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)
|
|
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_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:
|
|
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 _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:
|
|
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 = _safe_float(forecast.get("probability_up"), 0.5)
|
|
skill = _safe_float(forecast.get("skill"), 0.0)
|
|
min_edge = max(0.0, settings.time_series_min_edge_percent)
|
|
min_probability = _torch_min_probability(settings)
|
|
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)
|
|
quality_gate_ok = forecast.get("quality_gate_passed") is not False
|
|
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 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 >= 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 not bool(forecast.get("block_entry", False))
|
|
and 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,
|
|
"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"),
|
|
"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 = _safe_float(forecast.get("probability_up"), 0.5)
|
|
skill = _safe_float(forecast.get("skill"), 0.0)
|
|
min_edge = max(0.0, settings.time_series_min_edge_percent)
|
|
min_probability = _torch_min_probability(settings)
|
|
estimated_exit_net_percent = _estimated_exit_net_percent(position, price, 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),
|
|
"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:
|
|
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 < 0:
|
|
diagnostics["atr_exit_blocked_by_cost"] = True
|
|
return Signal(
|
|
position.symbol,
|
|
"HOLD",
|
|
0.45,
|
|
"torch_forecast: ATR trailing touched, but exit is not worth fees",
|
|
diagnostics,
|
|
)
|
|
return Signal(position.symbol, "SELL", 0.94, "torch_forecast: ATR trailing stop hit", 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,
|
|
)
|
|
if forecast_exit is not None:
|
|
action, confidence, reason = forecast_exit
|
|
return Signal(position.symbol, action, confidence, reason, diagnostics)
|
|
diagnostics["forecast_exit_blocked_by_cost"] = True
|
|
return Signal(
|
|
position.symbol,
|
|
"HOLD",
|
|
0.44,
|
|
(
|
|
"torch_forecast: forecast weakened, but exit is not worth fees; "
|
|
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 >= 0:
|
|
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,
|
|
)
|
|
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 _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 = _safe_float(forecast.get("probability_up"), 0.5)
|
|
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 = _safe_float(forecast.get("probability_up"), 0.5)
|
|
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 = _safe_float(forecast.get("probability_up"), 0.5)
|
|
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 = _safe_float(forecast.get("probability_up"), 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 _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 _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
|
|
)
|