Train Torch model for 12 spot pairs

This commit is contained in:
Курнат Андрей
2026-06-25 22:39:25 +03:00
parent 27205af73e
commit 87cb7e8fe3
18 changed files with 2326467 additions and 619561 deletions
+6 -1
View File
@@ -292,7 +292,12 @@ class CryptoSpotBot:
return worst.id
def _update_patterns(self) -> None:
if self.settings.strategy_mode in {"trend_macd", "torch_forecast"} or not self.settings.pattern_analysis_enabled:
patterns_needed = (
self.settings.pattern_analysis_enabled
or self.settings.grid_trading_enabled
or self.settings.rebound_trading_enabled
)
if self.settings.strategy_mode == "trend_macd" or not patterns_needed:
self.market.patterns = {}
return
patterns: dict[str, dict] = {}
+4 -5
View File
@@ -122,6 +122,7 @@ class Settings:
time_series_probe_min_edge_percent: float
time_series_probe_min_probability_up: float
time_series_probe_size_multiplier: float
time_series_rebound_fallback_enabled: bool
stop_loss_percent: float
take_profit_percent: float
trailing_stop_percent: float
@@ -190,11 +191,8 @@ def load_settings(env_file: str | Path | None = None) -> Settings:
raise ValueError("STRATEGY_MODE must be legacy, trend_macd or torch_forecast")
auto_select_symbols = _bool_env("AUTO_SELECT_SYMBOLS", False)
top_symbols_count = _int_env("TOP_SYMBOLS_COUNT", len(FIXED_SPOT_SYMBOLS))
symbols = _symbols_env("SYMBOLS") or FIXED_SPOT_SYMBOLS
if strategy_mode == "torch_forecast":
auto_select_symbols = False
top_symbols_count = len(FIXED_SPOT_SYMBOLS)
symbols = FIXED_SPOT_SYMBOLS
requested_symbols = _symbols_env("SYMBOLS")
symbols = requested_symbols if requested_symbols else (() if auto_select_symbols else FIXED_SPOT_SYMBOLS)
forecast_enabled_default = strategy_mode == "torch_forecast"
min_signal_confidence = _float_env("MIN_SIGNAL_CONFIDENCE", 0.64)
settings = Settings(
@@ -274,6 +272,7 @@ def load_settings(env_file: str | Path | None = None) -> Settings:
time_series_probe_min_edge_percent=_float_env("TIME_SERIES_PROBE_MIN_EDGE_PERCENT", 0.02),
time_series_probe_min_probability_up=_float_env("TIME_SERIES_PROBE_MIN_PROBABILITY_UP", 0.55),
time_series_probe_size_multiplier=_float_env("TIME_SERIES_PROBE_SIZE_MULTIPLIER", 0.40),
time_series_rebound_fallback_enabled=_bool_env("TIME_SERIES_REBOUND_FALLBACK_ENABLED", True),
stop_loss_percent=_float_env("STOP_LOSS_PERCENT", 0.04),
take_profit_percent=_float_env("TAKE_PROFIT_PERCENT", 0.035),
trailing_stop_percent=_float_env("TRAILING_STOP_PERCENT", 0.015),
+1
View File
@@ -265,6 +265,7 @@ def _safe_config(settings: Settings) -> dict[str, Any]:
"time_series_probe_min_edge_percent": settings.time_series_probe_min_edge_percent,
"time_series_probe_min_probability_up": settings.time_series_probe_min_probability_up,
"time_series_probe_size_multiplier": settings.time_series_probe_size_multiplier,
"time_series_rebound_fallback_enabled": settings.time_series_rebound_fallback_enabled,
"time_series_model_artifact": _time_series_model_artifact(settings),
"stop_loss_percent": settings.stop_loss_percent,
"take_profit_percent": settings.take_profit_percent,
+35 -8
View File
@@ -92,7 +92,7 @@ class PaperBroker:
return False, "достигнут лимит новых входов в минуту"
if len(self.positions) >= self.settings.max_open_positions:
return False, "достигнут общий лимит открытых позиций"
if self.settings.strategy_mode in {"trend_macd", "torch_forecast"} and len(self.positions_for_symbol(symbol)) >= 1:
if self.settings.strategy_mode == "trend_macd" and len(self.positions_for_symbol(symbol)) >= 1:
return False, "DCA/усреднение отключено: позиция по паре уже открыта"
dynamic_pair_limit = max(
self.settings.max_positions_per_symbol,
@@ -136,8 +136,9 @@ class PaperBroker:
) -> Position | None:
fill_price = self._buy_price(ticker)
notional = self._entry_budget(signal, ticker)
if notional < self.settings.min_position_usdt:
minimum_budget = self._minimum_entry_budget(instrument)
notional = self._entry_budget(signal, ticker, minimum_notional=minimum_budget)
if notional < max(self.settings.min_position_usdt, minimum_budget):
self.storage.event(f"{ticker.symbol}: покупка пропущена, adaptive-лимит экспозиции исчерпан", "WARN")
return None
notional = notional / (1 + self.settings.taker_fee_rate)
@@ -269,14 +270,31 @@ class PaperBroker:
value = default
return max(low, min(high, value))
def _entry_budget(self, signal: Signal, ticker: Ticker, extra_cap: float | None = None) -> float:
def _minimum_entry_budget(self, instrument: Instrument | None) -> float:
minimum = max(0.0, self.settings.min_position_usdt)
if instrument and instrument.min_notional_value > 0:
exchange_minimum = instrument.min_notional_value * (1 + self.settings.taker_fee_rate) * 1.002 + 0.01
minimum = max(minimum, exchange_minimum)
return minimum
def _entry_budget(
self,
signal: Signal,
ticker: Ticker,
extra_cap: float | None = None,
minimum_notional: float = 0.0,
) -> float:
available = max(0.0, self.cash - self.settings.min_cash_reserve_usdt)
rules = signal.diagnostics.get("adaptive_rules") or {}
target_total = self._adaptive_cap(rules, "target_total_exposure_usdt", self.settings.max_total_exposure_usdt)
target_symbol = self._adaptive_cap(rules, "target_symbol_exposure_usdt", self.settings.max_symbol_exposure_usdt)
exposure_room = max(0.0, target_total - self.exposure())
symbol_room = max(0.0, target_symbol - self.symbol_exposure(ticker.symbol))
caps = [self._signal_notional(signal), available, exposure_room, symbol_room]
requested = min(
max(self._signal_notional(signal), minimum_notional),
max(0.0, self.settings.max_position_usdt),
)
caps = [requested, available, exposure_room, symbol_room]
if extra_cap is not None:
caps.append(max(0.0, extra_cap))
return max(0.0, min(caps))
@@ -320,13 +338,22 @@ class LiveBroker(PaperBroker):
instrument: Instrument | None,
prices: dict[str, float],
) -> Position | None:
requested_notional = min(self._signal_notional(signal), self.settings.live_order_max_usdt)
minimum_budget = self._minimum_entry_budget(instrument)
requested_notional = min(
max(self._signal_notional(signal), minimum_budget),
self.settings.live_order_max_usdt,
)
allowed, reason = self.can_open(ticker.symbol, prices, requested_notional)
if not allowed:
self.storage.event(f"{ticker.symbol}: live BUY пропущен, {reason}", "WARN")
return None
budget = self._entry_budget(signal, ticker, self.settings.live_order_max_usdt)
if budget < self.settings.min_position_usdt:
budget = self._entry_budget(
signal,
ticker,
self.settings.live_order_max_usdt,
minimum_notional=minimum_budget,
)
if budget < max(self.settings.min_position_usdt, minimum_budget):
self.storage.event(f"{ticker.symbol}: live BUY skipped, adjusted budget below minimum", "WARN")
return None
signal.diagnostics["position_notional_usdt"] = budget
+168 -10
View File
@@ -28,8 +28,11 @@ class SpotStrategy:
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,
)
@@ -615,15 +618,18 @@ 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 > 0:
return Signal(symbol, "HOLD", 0.0, "torch_forecast: position for symbol is already open")
if open_positions_for_symbol >= _dynamic_symbol_position_limit(settings):
return Signal(symbol, "HOLD", 0.0, "torch_forecast: symbol position limit reached")
stop_loss_percent = _clamp(settings.stop_loss_percent, 0.003, 0.08)
sizing = _torch_forecast_position_sizing(settings, account, stop_loss_percent, forecast)
@@ -666,6 +672,55 @@ def _torch_forecast_entry_signal(
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"
checks = {
"torch_model_ok": model_ok,
"quality_gate_ok": quality_gate_ok,
@@ -695,6 +750,12 @@ def _torch_forecast_entry_signal(
"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,
"min_confidence": settings.time_series_min_confidence,
"skill": skill,
"quality_gate": forecast.get("quality_gate", {}),
@@ -703,27 +764,82 @@ def _torch_forecast_entry_signal(
"turnover_24h": ticker.turnover_24h,
"checks": checks,
"grid": {"enabled": False, "active": False},
"rebound": {"enabled": False, "active": False},
"rebound": rebound,
"learning": {},
"llm": {},
}
if all(checks.values()):
return Signal(
symbol,
"BUY",
confidence,
(
base_entry_ok = all(checks.values())
if base_entry_ok or rebound_entry_ok:
buy_confidence = max(confidence, float(rebound.get("probability", 0.0) or 0.0)) if rebound_entry_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,
@@ -749,14 +865,21 @@ def _torch_forecast_exit_signal(
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,
"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,
@@ -768,9 +891,29 @@ def _torch_forecast_exit_signal(
}
if 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:
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:
return Signal(
@@ -803,10 +946,25 @@ def _is_torch_forecast(forecast: dict) -> bool:
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:
exposure_based_limit = int(settings.max_symbol_exposure_usdt // max(settings.min_position_usdt, 0.01))
return max(1, settings.max_positions_per_symbol, 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)