Use Torch forecast as primary strategy

This commit is contained in:
Codex
2026-06-22 05:21:05 +03:00
parent 85fecbfc36
commit 544b0f4409
9 changed files with 374 additions and 20 deletions
+5 -6
View File
@@ -200,7 +200,7 @@ class CryptoSpotBot:
return enriched
def _close_paper_positions_outside_symbol_universe(self) -> None:
if self.settings.strategy_mode != "trend_macd" or self.settings.trading_mode != "paper":
if self.settings.strategy_mode not in {"trend_macd", "torch_forecast"} or self.settings.trading_mode != "paper":
return
allowed_symbols = set(self.market.symbols or self.settings.symbols)
for position in list(self.broker.open_positions()):
@@ -218,10 +218,10 @@ class CryptoSpotBot:
self.broker.sell(
position,
synthetic_ticker,
"trend_macd: закрыта старая paper-позиция вне списка разрешенных пар",
f"{self.settings.strategy_mode}: закрыта старая paper-позиция вне списка разрешенных пар",
)
self.storage.event(
f"{position.symbol}: старая paper-позиция закрыта при переходе на trend_macd"
f"{position.symbol}: старая paper-позиция закрыта при переходе на {self.settings.strategy_mode}"
)
def _reduction_candidate_id(self, prices: dict[str, float]) -> int | None:
@@ -242,7 +242,7 @@ class CryptoSpotBot:
return worst.id
def _update_patterns(self) -> None:
if self.settings.strategy_mode == "trend_macd" or not self.settings.pattern_analysis_enabled:
if self.settings.strategy_mode in {"trend_macd", "torch_forecast"} or not self.settings.pattern_analysis_enabled:
self.market.patterns = {}
return
patterns: dict[str, dict] = {}
@@ -256,8 +256,7 @@ class CryptoSpotBot:
def _update_forecasts(self) -> None:
if (
self.settings.strategy_mode == "trend_macd"
or self.forecaster is None
self.forecaster is None
or not self.settings.time_series_forecast_enabled
):
self.market.forecasts = {}
+15 -7
View File
@@ -6,7 +6,7 @@ from pathlib import Path
FIXED_SPOT_SYMBOLS = ("BTCUSDT", "ETHUSDT", "SOLUSDT")
STRATEGY_MODES = {"legacy", "trend_macd"}
STRATEGY_MODES = {"legacy", "trend_macd", "torch_forecast"}
def _load_dotenv(path: Path) -> None:
@@ -174,9 +174,17 @@ def load_settings(env_file: str | Path | None = None) -> Settings:
mode = os.getenv("TRADING_MODE", "paper").strip().lower()
if mode not in {"paper", "live"}:
raise ValueError("TRADING_MODE must be paper or live")
strategy_mode = os.getenv("STRATEGY_MODE", "trend_macd").strip().lower()
strategy_mode = os.getenv("STRATEGY_MODE", "torch_forecast").strip().lower()
if strategy_mode not in STRATEGY_MODES:
raise ValueError("STRATEGY_MODE must be legacy or trend_macd")
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
forecast_enabled_default = strategy_mode == "torch_forecast"
settings = Settings(
trading_mode=mode,
host=os.getenv("HOST", "127.0.0.1"),
@@ -185,9 +193,9 @@ def load_settings(env_file: str | Path | None = None) -> Settings:
bybit_api_key=os.getenv("BYBIT_API_KEY", ""),
bybit_api_secret=os.getenv("BYBIT_API_SECRET", ""),
starting_balance_usdt=_float_env("STARTING_BALANCE_USDT", 100.0),
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,
auto_select_symbols=auto_select_symbols,
top_symbols_count=top_symbols_count,
symbols=symbols,
strategy_mode=strategy_mode,
base_interval=os.getenv("BASE_INTERVAL", "60"),
kline_limit=_int_env("KLINE_LIMIT", 240),
@@ -236,7 +244,7 @@ def load_settings(env_file: str | Path | None = None) -> Settings:
atr_trailing_multiplier=_float_env("ATR_TRAILING_MULTIPLIER", 2.2),
trend_rsi_min=_float_env("TREND_RSI_MIN", 45.0),
trend_rsi_max=_float_env("TREND_RSI_MAX", 65.0),
time_series_forecast_enabled=_bool_env("TIME_SERIES_FORECAST_ENABLED", False),
time_series_forecast_enabled=_bool_env("TIME_SERIES_FORECAST_ENABLED", forecast_enabled_default),
time_series_min_candles=_int_env("TIME_SERIES_MIN_CANDLES", 120),
time_series_forecast_horizon=_int_env("TIME_SERIES_FORECAST_HORIZON", 3),
time_series_min_edge_percent=_float_env("TIME_SERIES_MIN_EDGE_PERCENT", 0.04),
+1 -1
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 == "trend_macd" and len(self.positions_for_symbol(symbol)) >= 1:
if self.settings.strategy_mode in {"trend_macd", "torch_forecast"} and len(self.positions_for_symbol(symbol)) >= 1:
return False, "DCA/усреднение отключено: позиция по паре уже открыта"
dynamic_pair_limit = max(
self.settings.max_positions_per_symbol,
+213
View File
@@ -24,6 +24,15 @@ class SpotStrategy:
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,
ticker=ticker,
open_positions_for_symbol=open_positions_for_symbol,
forecast=forecast or {},
account=account,
)
if self.settings.strategy_mode == "trend_macd":
return _trend_macd_entry_signal(
settings=self.settings,
@@ -354,6 +363,8 @@ class SpotStrategy:
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:
@@ -600,6 +611,208 @@ def _trend_macd_exit_signal(
return Signal(position.symbol, "HOLD", 0.35, "trend_macd: условия выхода не выполнены", diagnostics)
def _torch_forecast_entry_signal(
*,
settings: Settings,
symbol: str,
ticker: Ticker | None,
open_positions_for_symbol: int,
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")
stop_loss_percent = _clamp(settings.stop_loss_percent, 0.003, 0.08)
sizing = _torch_forecast_position_sizing(settings, account, stop_loss_percent, forecast)
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)
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)
checks = {
"torch_model_ok": model_ok,
"forecast_usable": bool(forecast.get("usable", False)),
"forecast_not_blocked": not bool(forecast.get("block_entry", False)),
"expected_edge_ok": expected_return >= min_edge,
"probability_ok": probability_up >= min_probability,
"skill_ok": skill > 0.0,
"confidence_ok": confidence >= settings.min_signal_confidence,
"spread_ok": spread_ok,
"liquidity_ok": liquidity_ok,
"risk_size_ok": position_notional >= settings.min_position_usdt,
}
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,
"probability_up": probability_up,
"min_probability_up": min_probability,
"skill": skill,
"spread_percent": round(ticker.spread_percent, 5),
"turnover_24h": ticker.turnover_24h,
"checks": checks,
"grid": {"enabled": False, "active": False},
"rebound": {"enabled": False, "active": False},
"learning": {},
"llm": {},
}
if all(checks.values()):
return Signal(
symbol,
"BUY",
confidence,
(
"torch_forecast: PyTorch edge confirmed; "
f"model={forecast.get('model')}, p_up={probability_up:.3f}, "
f"expected={expected_return:.4f}%, size={position_notional:.2f} USDT"
),
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_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 = max(position.stop_loss, position.entry_price * (1 - stop_loss_percent))
atr_multiplier = _clamp(settings.atr_trailing_multiplier, 0.5, 10.0)
atr_trailing_stop = None
if latest and latest.atr_14 is not None and position.highest_price > position.entry_price:
atr_trailing_stop = max(effective_stop_loss, position.highest_price - latest.atr_14 * atr_multiplier)
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)
diagnostics = {
"strategy_mode": "torch_forecast",
"price": price,
"entry_price": position.entry_price,
"stop_loss": effective_stop_loss,
"atr_trailing_stop": atr_trailing_stop,
"atr_trailing_multiplier": atr_multiplier,
"highest_price": position.highest_price,
"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,
}
if price <= effective_stop_loss:
return Signal(position.symbol, "SELL", 1.0, "torch_forecast: stop-loss 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):
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(
position.symbol,
"SELL",
0.86,
(
"torch_forecast: PyTorch forecast turned negative; "
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 _torch_min_probability(settings: Settings) -> float:
return round(_clamp(settings.min_signal_confidence - 0.08, 0.52, 0.68), 4)
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,
) -> dict[str, float | str]:
base = _trend_position_sizing(settings, account, stop_loss_percent)
base_notional = float(base["notional_usdt"])
if base_notional <= 0:
notional = 0.0
edge_multiplier = probability_multiplier = skill_multiplier = 0.0
else:
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)
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_risk",
"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),
}
def _has_trend_entry_indicators(current: Candle, previous: Candle, trend: Candle) -> bool:
return all(
value is not None