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
+8 -7
View File
@@ -8,8 +8,8 @@ BYBIT_API_SECRET=
STARTING_BALANCE_USDT=100 STARTING_BALANCE_USDT=100
AUTO_SELECT_SYMBOLS=false AUTO_SELECT_SYMBOLS=false
TOP_SYMBOLS_COUNT=4 TOP_SYMBOLS_COUNT=12
SYMBOLS=BTCUSDT,ETHUSDT,SOLUSDT,LTCUSDT SYMBOLS=BTCUSDT,ETHUSDT,HYPEUSDT,SOLUSDT,XRPUSDT,XPLUSDT,WLDUSDT,MNTUSDT,HUSDT,XAUTUSDT,IPUSDT,AAVEUSDT
STRATEGY_MODE=torch_forecast STRATEGY_MODE=torch_forecast
BASE_INTERVAL=60 BASE_INTERVAL=60
@@ -25,9 +25,9 @@ WEBSOCKET_ENABLED=true
MIN_SIGNAL_CONFIDENCE=0.64 MIN_SIGNAL_CONFIDENCE=0.64
MAX_SPREAD_PERCENT=0.18 MAX_SPREAD_PERCENT=0.18
MIN_24H_TURNOVER_USDT=1000000 MIN_24H_TURNOVER_USDT=1000000
PATTERN_ANALYSIS_ENABLED=false PATTERN_ANALYSIS_ENABLED=true
PATTERN_SCORE_WEIGHT=0.18 PATTERN_SCORE_WEIGHT=0.18
LEARNING_ENABLED=false LEARNING_ENABLED=true
LEARNING_LOOKBACK_TRADES=120 LEARNING_LOOKBACK_TRADES=120
LEARNING_MIN_SAMPLES=3 LEARNING_MIN_SAMPLES=3
LEARNING_MAX_ADJUSTMENT=0.12 LEARNING_MAX_ADJUSTMENT=0.12
@@ -42,9 +42,9 @@ GRID_TRADING_ENABLED=false
GRID_ENTRY_CONFIDENCE=0.58 GRID_ENTRY_CONFIDENCE=0.58
GRID_BUY_ZONE=0.45 GRID_BUY_ZONE=0.45
GRID_MAX_POSITION_USDT=8 GRID_MAX_POSITION_USDT=8
REBOUND_TRADING_ENABLED=false REBOUND_TRADING_ENABLED=true
REBOUND_ENTRY_CONFIDENCE=0.58 REBOUND_ENTRY_CONFIDENCE=0.55
REBOUND_MIN_PROBABILITY=0.58 REBOUND_MIN_PROBABILITY=0.55
REBOUND_MAX_POSITION_USDT=6 REBOUND_MAX_POSITION_USDT=6
KELLY_SIZING_ENABLED=false KELLY_SIZING_ENABLED=false
KELLY_FRACTION=0.25 KELLY_FRACTION=0.25
@@ -71,6 +71,7 @@ TIME_SERIES_PROBE_ENABLED=true
TIME_SERIES_PROBE_MIN_EDGE_PERCENT=0.02 TIME_SERIES_PROBE_MIN_EDGE_PERCENT=0.02
TIME_SERIES_PROBE_MIN_PROBABILITY_UP=0.55 TIME_SERIES_PROBE_MIN_PROBABILITY_UP=0.55
TIME_SERIES_PROBE_SIZE_MULTIPLIER=0.40 TIME_SERIES_PROBE_SIZE_MULTIPLIER=0.40
TIME_SERIES_REBOUND_FALLBACK_ENABLED=true
STOP_LOSS_PERCENT=0.04 STOP_LOSS_PERCENT=0.04
TAKE_PROFIT_PERCENT=0.035 TAKE_PROFIT_PERCENT=0.035
TRAILING_STOP_PERCENT=0.015 TRAILING_STOP_PERCENT=0.015
+8 -7
View File
@@ -8,8 +8,8 @@ BYBIT_API_SECRET=
STARTING_BALANCE_USDT=100 STARTING_BALANCE_USDT=100
AUTO_SELECT_SYMBOLS=false AUTO_SELECT_SYMBOLS=false
TOP_SYMBOLS_COUNT=4 TOP_SYMBOLS_COUNT=12
SYMBOLS=BTCUSDT,ETHUSDT,SOLUSDT,LTCUSDT SYMBOLS=BTCUSDT,ETHUSDT,HYPEUSDT,SOLUSDT,XRPUSDT,XPLUSDT,WLDUSDT,MNTUSDT,HUSDT,XAUTUSDT,IPUSDT,AAVEUSDT
STRATEGY_MODE=torch_forecast STRATEGY_MODE=torch_forecast
BASE_INTERVAL=60 BASE_INTERVAL=60
@@ -25,9 +25,9 @@ WEBSOCKET_ENABLED=true
MIN_SIGNAL_CONFIDENCE=0.64 MIN_SIGNAL_CONFIDENCE=0.64
MAX_SPREAD_PERCENT=0.18 MAX_SPREAD_PERCENT=0.18
MIN_24H_TURNOVER_USDT=1000000 MIN_24H_TURNOVER_USDT=1000000
PATTERN_ANALYSIS_ENABLED=false PATTERN_ANALYSIS_ENABLED=true
PATTERN_SCORE_WEIGHT=0.18 PATTERN_SCORE_WEIGHT=0.18
LEARNING_ENABLED=false LEARNING_ENABLED=true
LEARNING_LOOKBACK_TRADES=120 LEARNING_LOOKBACK_TRADES=120
LEARNING_MIN_SAMPLES=3 LEARNING_MIN_SAMPLES=3
LEARNING_MAX_ADJUSTMENT=0.12 LEARNING_MAX_ADJUSTMENT=0.12
@@ -42,9 +42,9 @@ GRID_TRADING_ENABLED=false
GRID_ENTRY_CONFIDENCE=0.58 GRID_ENTRY_CONFIDENCE=0.58
GRID_BUY_ZONE=0.45 GRID_BUY_ZONE=0.45
GRID_MAX_POSITION_USDT=8 GRID_MAX_POSITION_USDT=8
REBOUND_TRADING_ENABLED=false REBOUND_TRADING_ENABLED=true
REBOUND_ENTRY_CONFIDENCE=0.58 REBOUND_ENTRY_CONFIDENCE=0.55
REBOUND_MIN_PROBABILITY=0.58 REBOUND_MIN_PROBABILITY=0.55
REBOUND_MAX_POSITION_USDT=6 REBOUND_MAX_POSITION_USDT=6
KELLY_SIZING_ENABLED=false KELLY_SIZING_ENABLED=false
KELLY_FRACTION=0.25 KELLY_FRACTION=0.25
@@ -71,6 +71,7 @@ TIME_SERIES_PROBE_ENABLED=true
TIME_SERIES_PROBE_MIN_EDGE_PERCENT=0.02 TIME_SERIES_PROBE_MIN_EDGE_PERCENT=0.02
TIME_SERIES_PROBE_MIN_PROBABILITY_UP=0.55 TIME_SERIES_PROBE_MIN_PROBABILITY_UP=0.55
TIME_SERIES_PROBE_SIZE_MULTIPLIER=0.40 TIME_SERIES_PROBE_SIZE_MULTIPLIER=0.40
TIME_SERIES_REBOUND_FALLBACK_ENABLED=true
STOP_LOSS_PERCENT=0.04 STOP_LOSS_PERCENT=0.04
TAKE_PROFIT_PERCENT=0.035 TAKE_PROFIT_PERCENT=0.035
TRAILING_STOP_PERCENT=0.015 TRAILING_STOP_PERCENT=0.015
+8 -7
View File
@@ -116,8 +116,8 @@ Dashboard: `http://<host>:8787/`
TRADING_MODE=paper TRADING_MODE=paper
STARTING_BALANCE_USDT=100 STARTING_BALANCE_USDT=100
AUTO_SELECT_SYMBOLS=false AUTO_SELECT_SYMBOLS=false
TOP_SYMBOLS_COUNT=4 TOP_SYMBOLS_COUNT=12
SYMBOLS=BTCUSDT,ETHUSDT,SOLUSDT,LTCUSDT SYMBOLS=BTCUSDT,ETHUSDT,HYPEUSDT,SOLUSDT,XRPUSDT,XPLUSDT,WLDUSDT,MNTUSDT,HUSDT,XAUTUSDT,IPUSDT,AAVEUSDT
STRATEGY_MODE=torch_forecast STRATEGY_MODE=torch_forecast
BASE_INTERVAL=60 BASE_INTERVAL=60
TREND_INTERVAL=D TREND_INTERVAL=D
@@ -129,9 +129,9 @@ FAST_ENTRY_COOLDOWN_SECONDS=20
MAX_ENTRIES_PER_MINUTE=12 MAX_ENTRIES_PER_MINUTE=12
WEBSOCKET_ENABLED=true WEBSOCKET_ENABLED=true
MIN_SIGNAL_CONFIDENCE=0.64 MIN_SIGNAL_CONFIDENCE=0.64
PATTERN_ANALYSIS_ENABLED=false PATTERN_ANALYSIS_ENABLED=true
PATTERN_SCORE_WEIGHT=0.18 PATTERN_SCORE_WEIGHT=0.18
LEARNING_ENABLED=false LEARNING_ENABLED=true
LEARNING_LOOKBACK_TRADES=120 LEARNING_LOOKBACK_TRADES=120
LEARNING_MIN_SAMPLES=3 LEARNING_MIN_SAMPLES=3
LEARNING_MAX_ADJUSTMENT=0.12 LEARNING_MAX_ADJUSTMENT=0.12
@@ -146,9 +146,9 @@ GRID_TRADING_ENABLED=false
GRID_ENTRY_CONFIDENCE=0.58 GRID_ENTRY_CONFIDENCE=0.58
GRID_BUY_ZONE=0.45 GRID_BUY_ZONE=0.45
GRID_MAX_POSITION_USDT=8 GRID_MAX_POSITION_USDT=8
REBOUND_TRADING_ENABLED=false REBOUND_TRADING_ENABLED=true
REBOUND_ENTRY_CONFIDENCE=0.58 REBOUND_ENTRY_CONFIDENCE=0.55
REBOUND_MIN_PROBABILITY=0.58 REBOUND_MIN_PROBABILITY=0.55
REBOUND_MAX_POSITION_USDT=6 REBOUND_MAX_POSITION_USDT=6
KELLY_SIZING_ENABLED=false KELLY_SIZING_ENABLED=false
KELLY_FRACTION=0.25 KELLY_FRACTION=0.25
@@ -175,6 +175,7 @@ TIME_SERIES_PROBE_ENABLED=true
TIME_SERIES_PROBE_MIN_EDGE_PERCENT=0.02 TIME_SERIES_PROBE_MIN_EDGE_PERCENT=0.02
TIME_SERIES_PROBE_MIN_PROBABILITY_UP=0.55 TIME_SERIES_PROBE_MIN_PROBABILITY_UP=0.55
TIME_SERIES_PROBE_SIZE_MULTIPLIER=0.40 TIME_SERIES_PROBE_SIZE_MULTIPLIER=0.40
TIME_SERIES_REBOUND_FALLBACK_ENABLED=true
STOP_LOSS_PERCENT=0.04 STOP_LOSS_PERCENT=0.04
TAKE_PROFIT_PERCENT=0.035 TAKE_PROFIT_PERCENT=0.035
TRAILING_STOP_PERCENT=0.015 TRAILING_STOP_PERCENT=0.015
+6 -1
View File
@@ -292,7 +292,12 @@ class CryptoSpotBot:
return worst.id return worst.id
def _update_patterns(self) -> None: 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 = {} self.market.patterns = {}
return return
patterns: dict[str, dict] = {} 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_edge_percent: float
time_series_probe_min_probability_up: float time_series_probe_min_probability_up: float
time_series_probe_size_multiplier: float time_series_probe_size_multiplier: float
time_series_rebound_fallback_enabled: bool
stop_loss_percent: float stop_loss_percent: float
take_profit_percent: float take_profit_percent: float
trailing_stop_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") raise ValueError("STRATEGY_MODE must be legacy, trend_macd or torch_forecast")
auto_select_symbols = _bool_env("AUTO_SELECT_SYMBOLS", False) auto_select_symbols = _bool_env("AUTO_SELECT_SYMBOLS", False)
top_symbols_count = _int_env("TOP_SYMBOLS_COUNT", len(FIXED_SPOT_SYMBOLS)) top_symbols_count = _int_env("TOP_SYMBOLS_COUNT", len(FIXED_SPOT_SYMBOLS))
symbols = _symbols_env("SYMBOLS") or FIXED_SPOT_SYMBOLS requested_symbols = _symbols_env("SYMBOLS")
if strategy_mode == "torch_forecast": symbols = requested_symbols if requested_symbols else (() if auto_select_symbols else FIXED_SPOT_SYMBOLS)
auto_select_symbols = False
top_symbols_count = len(FIXED_SPOT_SYMBOLS)
symbols = FIXED_SPOT_SYMBOLS
forecast_enabled_default = strategy_mode == "torch_forecast" forecast_enabled_default = strategy_mode == "torch_forecast"
min_signal_confidence = _float_env("MIN_SIGNAL_CONFIDENCE", 0.64) min_signal_confidence = _float_env("MIN_SIGNAL_CONFIDENCE", 0.64)
settings = Settings( 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_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_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_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), stop_loss_percent=_float_env("STOP_LOSS_PERCENT", 0.04),
take_profit_percent=_float_env("TAKE_PROFIT_PERCENT", 0.035), take_profit_percent=_float_env("TAKE_PROFIT_PERCENT", 0.035),
trailing_stop_percent=_float_env("TRAILING_STOP_PERCENT", 0.015), 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_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_min_probability_up": settings.time_series_probe_min_probability_up,
"time_series_probe_size_multiplier": settings.time_series_probe_size_multiplier, "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), "time_series_model_artifact": _time_series_model_artifact(settings),
"stop_loss_percent": settings.stop_loss_percent, "stop_loss_percent": settings.stop_loss_percent,
"take_profit_percent": settings.take_profit_percent, "take_profit_percent": settings.take_profit_percent,
+35 -8
View File
@@ -92,7 +92,7 @@ class PaperBroker:
return False, "достигнут лимит новых входов в минуту" return False, "достигнут лимит новых входов в минуту"
if len(self.positions) >= self.settings.max_open_positions: if len(self.positions) >= self.settings.max_open_positions:
return False, "достигнут общий лимит открытых позиций" 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/усреднение отключено: позиция по паре уже открыта" return False, "DCA/усреднение отключено: позиция по паре уже открыта"
dynamic_pair_limit = max( dynamic_pair_limit = max(
self.settings.max_positions_per_symbol, self.settings.max_positions_per_symbol,
@@ -136,8 +136,9 @@ class PaperBroker:
) -> Position | None: ) -> Position | None:
fill_price = self._buy_price(ticker) fill_price = self._buy_price(ticker)
notional = self._entry_budget(signal, ticker) minimum_budget = self._minimum_entry_budget(instrument)
if notional < self.settings.min_position_usdt: 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") self.storage.event(f"{ticker.symbol}: покупка пропущена, adaptive-лимит экспозиции исчерпан", "WARN")
return None return None
notional = notional / (1 + self.settings.taker_fee_rate) notional = notional / (1 + self.settings.taker_fee_rate)
@@ -269,14 +270,31 @@ class PaperBroker:
value = default value = default
return max(low, min(high, value)) 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) available = max(0.0, self.cash - self.settings.min_cash_reserve_usdt)
rules = signal.diagnostics.get("adaptive_rules") or {} rules = signal.diagnostics.get("adaptive_rules") or {}
target_total = self._adaptive_cap(rules, "target_total_exposure_usdt", self.settings.max_total_exposure_usdt) 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) 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()) exposure_room = max(0.0, target_total - self.exposure())
symbol_room = max(0.0, target_symbol - self.symbol_exposure(ticker.symbol)) 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: if extra_cap is not None:
caps.append(max(0.0, extra_cap)) caps.append(max(0.0, extra_cap))
return max(0.0, min(caps)) return max(0.0, min(caps))
@@ -320,13 +338,22 @@ class LiveBroker(PaperBroker):
instrument: Instrument | None, instrument: Instrument | None,
prices: dict[str, float], prices: dict[str, float],
) -> Position | None: ) -> 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) allowed, reason = self.can_open(ticker.symbol, prices, requested_notional)
if not allowed: if not allowed:
self.storage.event(f"{ticker.symbol}: live BUY пропущен, {reason}", "WARN") self.storage.event(f"{ticker.symbol}: live BUY пропущен, {reason}", "WARN")
return None return None
budget = self._entry_budget(signal, ticker, self.settings.live_order_max_usdt) budget = self._entry_budget(
if budget < self.settings.min_position_usdt: 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") self.storage.event(f"{ticker.symbol}: live BUY skipped, adjusted budget below minimum", "WARN")
return None return None
signal.diagnostics["position_notional_usdt"] = budget signal.diagnostics["position_notional_usdt"] = budget
+168 -10
View File
@@ -28,8 +28,11 @@ class SpotStrategy:
return _torch_forecast_entry_signal( return _torch_forecast_entry_signal(
settings=self.settings, settings=self.settings,
symbol=symbol, symbol=symbol,
candles=candles,
ticker=ticker, ticker=ticker,
open_positions_for_symbol=open_positions_for_symbol, open_positions_for_symbol=open_positions_for_symbol,
pattern=pattern or {},
llm=llm or {},
forecast=forecast or {}, forecast=forecast or {},
account=account, account=account,
) )
@@ -615,15 +618,18 @@ def _torch_forecast_entry_signal(
*, *,
settings: Settings, settings: Settings,
symbol: str, symbol: str,
candles: list[Candle] | None,
ticker: Ticker | None, ticker: Ticker | None,
open_positions_for_symbol: int, open_positions_for_symbol: int,
pattern: dict,
llm: dict,
forecast: dict, forecast: dict,
account: dict | None, account: dict | None,
) -> Signal: ) -> Signal:
if ticker is None: if ticker is None:
return Signal(symbol, "HOLD", 0.0, "torch_forecast: no ticker data") return Signal(symbol, "HOLD", 0.0, "torch_forecast: no ticker data")
if open_positions_for_symbol > 0: if open_positions_for_symbol >= _dynamic_symbol_position_limit(settings):
return Signal(symbol, "HOLD", 0.0, "torch_forecast: position for symbol is already open") return Signal(symbol, "HOLD", 0.0, "torch_forecast: symbol position limit reached")
stop_loss_percent = _clamp(settings.stop_loss_percent, 0.003, 0.08) stop_loss_percent = _clamp(settings.stop_loss_percent, 0.003, 0.08)
sizing = _torch_forecast_position_sizing(settings, account, stop_loss_percent, forecast) 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 liquidity_ok = ticker.turnover_24h >= settings.min_24h_turnover_usdt
model_ok = _is_torch_forecast(forecast) model_ok = _is_torch_forecast(forecast)
quality_gate_ok = forecast.get("quality_gate_passed") is not False 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 = { checks = {
"torch_model_ok": model_ok, "torch_model_ok": model_ok,
"quality_gate_ok": quality_gate_ok, "quality_gate_ok": quality_gate_ok,
@@ -695,6 +750,12 @@ def _torch_forecast_entry_signal(
"edge_mode": edge_mode, "edge_mode": edge_mode,
"probability_up": probability_up, "probability_up": probability_up,
"min_probability_up": min_probability, "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, "min_confidence": settings.time_series_min_confidence,
"skill": skill, "skill": skill,
"quality_gate": forecast.get("quality_gate", {}), "quality_gate": forecast.get("quality_gate", {}),
@@ -703,27 +764,82 @@ def _torch_forecast_entry_signal(
"turnover_24h": ticker.turnover_24h, "turnover_24h": ticker.turnover_24h,
"checks": checks, "checks": checks,
"grid": {"enabled": False, "active": False}, "grid": {"enabled": False, "active": False},
"rebound": {"enabled": False, "active": False}, "rebound": rebound,
"learning": {}, "learning": {},
"llm": {}, "llm": {},
} }
if all(checks.values()): base_entry_ok = all(checks.values())
return Signal( if base_entry_ok or rebound_entry_ok:
symbol, buy_confidence = max(confidence, float(rebound.get("probability", 0.0) or 0.0)) if rebound_entry_ok else confidence
"BUY", entry_path = edge_mode if rebound_entry_ok and not base_entry_ok else edge_mode
confidence, 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; " "torch_forecast: PyTorch edge confirmed; "
f"model={forecast.get('model')}, p_up={probability_up:.3f}, " f"model={forecast.get('model')}, p_up={probability_up:.3f}, "
f"expected={expected_return:.4f}%, edge_mode={edge_mode}, " f"expected={expected_return:.4f}%, edge_mode={edge_mode}, "
f"size={position_notional:.2f} USDT" f"size={position_notional:.2f} USDT"
), )
return Signal(
symbol,
"BUY",
round(_clamp(buy_confidence, 0.0, 0.96), 4),
reason,
diagnostics, diagnostics,
) )
failed = ", ".join(name for name, ok in checks.items() if not ok) failed = ", ".join(name for name, ok in checks.items() if not ok)
return Signal(symbol, "HOLD", confidence, f"torch_forecast: entry blocked ({failed})", diagnostics) 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( def _torch_forecast_exit_signal(
settings: Settings, settings: Settings,
position: Position, position: Position,
@@ -749,14 +865,21 @@ def _torch_forecast_exit_signal(
min_edge = max(0.0, settings.time_series_min_edge_percent) min_edge = max(0.0, settings.time_series_min_edge_percent)
min_probability = _torch_min_probability(settings) min_probability = _torch_min_probability(settings)
estimated_exit_net_percent = _estimated_exit_net_percent(position, price, 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 = { diagnostics = {
"strategy_mode": "torch_forecast", "strategy_mode": "torch_forecast",
"price": price, "price": price,
"entry_price": position.entry_price, "entry_price": position.entry_price,
"stop_loss": effective_stop_loss, "stop_loss": effective_stop_loss,
"take_profit": position.take_profit,
"atr_trailing_stop": atr_trailing_stop, "atr_trailing_stop": atr_trailing_stop,
"atr_trailing_multiplier": atr_multiplier, "atr_trailing_multiplier": atr_multiplier,
"highest_price": position.highest_price, "highest_price": position.highest_price,
"entry_path": entry_path,
"entry_edge_mode": entry_edge_mode,
"rebound_fallback_position": rebound_fallback_position,
"forecast": forecast, "forecast": forecast,
"expected_return_percent": expected_return, "expected_return_percent": expected_return,
"min_edge_percent": min_edge, "min_edge_percent": min_edge,
@@ -768,9 +891,29 @@ def _torch_forecast_exit_signal(
} }
if price <= effective_stop_loss: if price <= effective_stop_loss:
return Signal(position.symbol, "SELL", 1.0, "torch_forecast: stop-loss hit", diagnostics) 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 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) return Signal(position.symbol, "SELL", 0.94, "torch_forecast: ATR trailing stop hit", diagnostics)
if not _is_torch_forecast(forecast): 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) 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 bool(forecast.get("block_entry", False)) or expected_return <= 0.0 or probability_up <= 0.50:
return Signal( 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"} 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: def _torch_min_probability(settings: Settings) -> float:
return round(_clamp(settings.time_series_min_probability_up, 0.45, 0.75), 4) 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: def _torch_forecast_confidence(settings: Settings, forecast: dict) -> float:
expected_return = max(0.0, _safe_float(forecast.get("expected_return_percent"), 0.0)) expected_return = max(0.0, _safe_float(forecast.get("expected_return_percent"), 0.0))
probability_up = _safe_float(forecast.get("probability_up"), 0.5) probability_up = _safe_float(forecast.get("probability_up"), 0.5)
File diff suppressed because it is too large Load Diff
+1160321 -308065
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1
View File
@@ -87,6 +87,7 @@ def make_settings():
time_series_probe_min_edge_percent=0.02, time_series_probe_min_edge_percent=0.02,
time_series_probe_min_probability_up=0.55, time_series_probe_min_probability_up=0.55,
time_series_probe_size_multiplier=0.40, time_series_probe_size_multiplier=0.40,
time_series_rebound_fallback_enabled=True,
stop_loss_percent=0.02, stop_loss_percent=0.02,
take_profit_percent=0.035, take_profit_percent=0.035,
trailing_stop_percent=0.015, trailing_stop_percent=0.015,
+29 -4
View File
@@ -84,7 +84,7 @@ def test_default_symbols_are_fixed_trend_pairs(tmp_path, monkeypatch) -> None:
assert settings.time_series_forecast_enabled is True assert settings.time_series_forecast_enabled is True
def test_torch_forecast_forces_fixed_symbols(tmp_path, monkeypatch) -> None: def test_torch_forecast_keeps_configured_symbol_selection(tmp_path, monkeypatch) -> None:
for key in ( for key in (
"AUTO_SELECT_SYMBOLS", "AUTO_SELECT_SYMBOLS",
"TOP_SYMBOLS_COUNT", "TOP_SYMBOLS_COUNT",
@@ -110,7 +110,32 @@ def test_torch_forecast_forces_fixed_symbols(tmp_path, monkeypatch) -> None:
settings = load_settings(env_file) settings = load_settings(env_file)
assert settings.auto_select_symbols is False assert settings.auto_select_symbols is True
assert settings.top_symbols_count == len(FIXED_SPOT_SYMBOLS) assert settings.top_symbols_count == 9
assert settings.symbols == FIXED_SPOT_SYMBOLS assert settings.symbols == ("DOGEUSDT", "XRPUSDT")
assert settings.time_series_forecast_enabled is True assert settings.time_series_forecast_enabled is True
def test_auto_select_uses_empty_symbol_list(tmp_path, monkeypatch) -> None:
for key in ("AUTO_SELECT_SYMBOLS", "TOP_SYMBOLS_COUNT", "SYMBOLS", "STRATEGY_MODE"):
monkeypatch.delenv(key, raising=False)
monkeypatch.setenv("TRADING_MODE", "paper")
env_file = tmp_path / ".env"
env_file.write_text(
"\n".join(
[
"TRADING_MODE=paper",
"STRATEGY_MODE=torch_forecast",
"AUTO_SELECT_SYMBOLS=true",
"TOP_SYMBOLS_COUNT=12",
"SYMBOLS=",
]
),
encoding="utf-8",
)
settings = load_settings(env_file)
assert settings.auto_select_symbols is True
assert settings.top_symbols_count == 12
assert settings.symbols == ()
+1
View File
@@ -43,6 +43,7 @@ def test_safe_config_summarizes_torch_forecast_artifact(make_settings, tmp_path)
assert config["time_series_probe_min_edge_percent"] == 0.02 assert config["time_series_probe_min_edge_percent"] == 0.02
assert config["time_series_probe_min_probability_up"] == 0.55 assert config["time_series_probe_min_probability_up"] == 0.55
assert config["time_series_probe_size_multiplier"] == 0.40 assert config["time_series_probe_size_multiplier"] == 0.40
assert config["time_series_rebound_fallback_enabled"] is True
assert config["time_series_model_artifact"] == { assert config["time_series_model_artifact"] == {
"available": True, "available": True,
"type": "pytorch_recurrent_forecaster", "type": "pytorch_recurrent_forecaster",
+36 -3
View File
@@ -54,6 +54,7 @@ def test_paper_broker_limits_fast_entries_per_minute(make_settings, tmp_path) ->
def test_paper_broker_uses_signal_notional_and_pair_exposure(make_settings, tmp_path) -> None: def test_paper_broker_uses_signal_notional_and_pair_exposure(make_settings, tmp_path) -> None:
settings = make_settings( settings = make_settings(
tmp_path, tmp_path,
strategy_mode="torch_forecast",
min_position_usdt=1, min_position_usdt=1,
max_position_usdt=20, max_position_usdt=20,
max_symbol_exposure_usdt=6, max_symbol_exposure_usdt=6,
@@ -100,6 +101,34 @@ def test_paper_broker_uses_signal_notional_and_pair_exposure(make_settings, tmp_
assert 5.5 <= broker.symbol_exposure("BTCUSDT") <= 6.0 assert 5.5 <= broker.symbol_exposure("BTCUSDT") <= 6.0
def test_paper_broker_raises_small_signal_to_exchange_min_notional(make_settings, tmp_path) -> None:
settings = make_settings(
tmp_path,
min_position_usdt=1,
max_position_usdt=20,
max_symbol_exposure_usdt=20,
max_total_exposure_usdt=80,
max_open_positions=20,
max_positions_per_symbol=20,
max_entries_per_minute=0,
)
storage = Storage(settings.database_path)
broker = PaperBroker(settings, storage)
ticker = Ticker("XRPUSDT", 1.0, 0.999, 1.001, 10_000_000, 100, 0)
instrument = Instrument("XRPUSDT", "XRP", "USDT", "Trading", 0.0001, 0.01, 0.01, 5)
position = broker.buy(
Signal("XRPUSDT", "BUY", 0.8, "small rebound", {"position_notional_usdt": 1.5}),
ticker,
instrument,
{"XRPUSDT": 1.0},
)
assert position is not None
assert position.notional_usdt >= instrument.min_notional_value
assert position.notional_usdt <= settings.max_position_usdt
def test_paper_broker_respects_adaptive_exposure_target(make_settings, tmp_path) -> None: def test_paper_broker_respects_adaptive_exposure_target(make_settings, tmp_path) -> None:
settings = make_settings( settings = make_settings(
tmp_path, tmp_path,
@@ -160,7 +189,7 @@ def test_trend_macd_broker_blocks_dca_for_same_symbol(make_settings, tmp_path) -
assert len(broker.open_positions()) == 1 assert len(broker.open_positions()) == 1
def test_torch_forecast_broker_blocks_dca_for_same_symbol(make_settings, tmp_path) -> None: def test_torch_forecast_broker_allows_dynamic_entries_until_total_limit(make_settings, tmp_path) -> None:
settings = make_settings( settings = make_settings(
tmp_path, tmp_path,
strategy_mode="torch_forecast", strategy_mode="torch_forecast",
@@ -179,10 +208,14 @@ def test_torch_forecast_broker_blocks_dca_for_same_symbol(make_settings, tmp_pat
first = broker.buy(Signal("BTCUSDT", "BUY", 0.8, "first", {"position_notional_usdt": 2}), ticker, instrument, {"BTCUSDT": 100}) first = broker.buy(Signal("BTCUSDT", "BUY", 0.8, "first", {"position_notional_usdt": 2}), ticker, instrument, {"BTCUSDT": 100})
second = broker.buy(Signal("BTCUSDT", "BUY", 0.8, "second", {"position_notional_usdt": 2}), ticker, instrument, {"BTCUSDT": 100}) second = broker.buy(Signal("BTCUSDT", "BUY", 0.8, "second", {"position_notional_usdt": 2}), ticker, instrument, {"BTCUSDT": 100})
third = broker.buy(Signal("BTCUSDT", "BUY", 0.8, "third", {"position_notional_usdt": 2}), ticker, instrument, {"BTCUSDT": 100})
fourth = broker.buy(Signal("BTCUSDT", "BUY", 0.8, "fourth", {"position_notional_usdt": 2}), ticker, instrument, {"BTCUSDT": 100})
assert first is not None assert first is not None
assert second is None assert second is not None
assert len(broker.open_positions()) == 1 assert third is not None
assert fourth is None
assert len(broker.open_positions()) == 3
def test_trend_macd_closes_old_paper_positions_outside_symbol_universe(make_settings, tmp_path) -> None: def test_trend_macd_closes_old_paper_positions_outside_symbol_universe(make_settings, tmp_path) -> None:
+296
View File
@@ -284,6 +284,50 @@ def test_torch_forecast_blocks_without_valid_torch_model(make_settings, tmp_path
assert signal.diagnostics["checks"]["torch_model_ok"] is False assert signal.diagnostics["checks"]["torch_model_ok"] is False
def test_torch_forecast_allows_additional_entries_until_symbol_limit(make_settings, tmp_path) -> None:
settings = make_settings(
tmp_path,
strategy_mode="torch_forecast",
min_position_usdt=1,
max_symbol_exposure_usdt=3,
max_positions_per_symbol=3,
max_position_usdt=25,
stop_loss_percent=0.04,
risk_per_trade_percent=0.01,
)
strategy = SpotStrategy(settings)
ticker = Ticker("BTCUSDT", 105, 104.99, 105.01, 10_000_000, 1000, 1.0)
forecast = {
"usable": True,
"model": "torch_gru",
"expected_return_percent": 0.36,
"probability_up": 0.66,
"skill": 0.22,
"block_entry": False,
}
additional = strategy.entry_signal(
"BTCUSDT",
[],
ticker,
open_positions_for_symbol=1,
forecast=forecast,
account={"equity": 100.0},
)
capped = strategy.entry_signal(
"BTCUSDT",
[],
ticker,
open_positions_for_symbol=3,
forecast=forecast,
account={"equity": 100.0},
)
assert additional.action == "BUY"
assert capped.action == "HOLD"
assert "symbol position limit" in capped.reason
def test_torch_forecast_blocks_failed_quality_gate(make_settings, tmp_path) -> None: def test_torch_forecast_blocks_failed_quality_gate(make_settings, tmp_path) -> None:
settings = make_settings( settings = make_settings(
tmp_path, tmp_path,
@@ -392,6 +436,190 @@ def test_torch_forecast_probe_blocks_negative_expected_return(make_settings, tmp
assert signal.diagnostics["checks"]["expected_edge_ok"] is False assert signal.diagnostics["checks"]["expected_edge_ok"] is False
def test_torch_forecast_rebound_overlay_buys_stabilized_drop(make_settings, tmp_path) -> None:
settings = make_settings(
tmp_path,
strategy_mode="torch_forecast",
rebound_trading_enabled=True,
rebound_entry_confidence=0.55,
rebound_min_probability=0.55,
rebound_max_position_usdt=6.0,
time_series_min_edge_percent=0.10,
time_series_probe_min_probability_up=0.55,
max_position_usdt=25,
stop_loss_percent=0.04,
risk_per_trade_percent=0.01,
)
strategy = SpotStrategy(settings)
candles = _rebound_candles()
ticker = Ticker(
symbol="BTCUSDT",
last_price=candles[-1].close,
bid=candles[-1].close * 0.9999,
ask=candles[-1].close * 1.0001,
turnover_24h=10_000_000,
volume_24h=1000,
change_24h=-2.0,
)
signal = strategy.entry_signal(
"BTCUSDT",
candles,
ticker,
open_positions_for_symbol=0,
pattern={"label": "нисходящий тренд", "score": 0.28},
forecast={
"usable": True,
"model": "torch_gru",
"expected_return_percent": 0.01,
"probability_up": 0.56,
"skill": 0.05,
"block_entry": False,
},
account={"equity": 100.0},
)
assert signal.action == "BUY"
assert signal.diagnostics["entry_path"] == "rebound"
assert signal.diagnostics["rebound"]["active"] is True
assert signal.diagnostics["edge_mode"] == "rebound"
assert signal.diagnostics["checks"]["expected_edge_ok"] is False
assert signal.diagnostics["position_notional_usdt"] <= settings.rebound_max_position_usdt
def test_torch_forecast_rebound_overlay_does_not_buy_negative_forecast(make_settings, tmp_path) -> None:
settings = make_settings(
tmp_path,
strategy_mode="torch_forecast",
rebound_trading_enabled=True,
rebound_entry_confidence=0.55,
rebound_min_probability=0.55,
time_series_min_edge_percent=0.10,
time_series_probe_min_probability_up=0.55,
)
strategy = SpotStrategy(settings)
candles = _rebound_candles()
ticker = Ticker(
symbol="BTCUSDT",
last_price=candles[-1].close,
bid=candles[-1].close * 0.9999,
ask=candles[-1].close * 1.0001,
turnover_24h=10_000_000,
volume_24h=1000,
change_24h=-2.0,
)
signal = strategy.entry_signal(
"BTCUSDT",
candles,
ticker,
open_positions_for_symbol=0,
pattern={"label": "нисходящий тренд", "score": 0.28},
forecast={
"usable": True,
"model": "torch_gru",
"expected_return_percent": -0.01,
"probability_up": 0.56,
"skill": 0.05,
"block_entry": False,
},
account={"equity": 100.0},
)
assert signal.action == "HOLD"
assert signal.diagnostics["rebound"]["active"] is True
assert signal.diagnostics["rebound_entry_ok"] is False
assert signal.diagnostics["edge_mode"] == "blocked"
def test_torch_forecast_rebound_fallback_buys_when_symbol_has_no_model(make_settings, tmp_path) -> None:
settings = make_settings(
tmp_path,
strategy_mode="torch_forecast",
rebound_trading_enabled=True,
rebound_entry_confidence=0.55,
rebound_min_probability=0.55,
rebound_max_position_usdt=6.0,
time_series_rebound_fallback_enabled=True,
stop_loss_percent=0.04,
risk_per_trade_percent=0.01,
)
strategy = SpotStrategy(settings)
candles = _rebound_candles()
ticker = Ticker(
symbol="HYPEUSDT",
last_price=candles[-1].close,
bid=candles[-1].close * 0.9999,
ask=candles[-1].close * 1.0001,
turnover_24h=10_000_000,
volume_24h=1000,
change_24h=-2.0,
)
signal = strategy.entry_signal(
"HYPEUSDT",
candles,
ticker,
open_positions_for_symbol=0,
pattern={"label": "нисходящий тренд", "score": 0.28},
forecast={
"usable": False,
"model": "none",
"reason": "no valid PyTorch LSTM/GRU model for symbol",
"block_entry": False,
},
account={"equity": 100.0},
)
assert signal.action == "BUY"
assert signal.diagnostics["entry_path"] == "rebound_fallback"
assert signal.diagnostics["fallback_rebound_entry_ok"] is True
assert signal.diagnostics["missing_torch_model"] is True
assert signal.diagnostics["edge_mode"] == "rebound_fallback"
assert signal.diagnostics["position_notional_usdt"] <= settings.rebound_max_position_usdt
def test_torch_forecast_rebound_fallback_can_be_disabled(make_settings, tmp_path) -> None:
settings = make_settings(
tmp_path,
strategy_mode="torch_forecast",
rebound_trading_enabled=True,
rebound_entry_confidence=0.55,
rebound_min_probability=0.55,
time_series_rebound_fallback_enabled=False,
)
strategy = SpotStrategy(settings)
candles = _rebound_candles()
ticker = Ticker(
symbol="HYPEUSDT",
last_price=candles[-1].close,
bid=candles[-1].close * 0.9999,
ask=candles[-1].close * 1.0001,
turnover_24h=10_000_000,
volume_24h=1000,
change_24h=-2.0,
)
signal = strategy.entry_signal(
"HYPEUSDT",
candles,
ticker,
open_positions_for_symbol=0,
pattern={"label": "нисходящий тренд", "score": 0.28},
forecast={
"usable": False,
"model": "none",
"reason": "no valid PyTorch LSTM/GRU model for symbol",
"block_entry": False,
},
account={"equity": 100.0},
)
assert signal.action == "HOLD"
assert signal.diagnostics["missing_torch_model"] is True
assert signal.diagnostics["fallback_rebound_entry_ok"] is False
def test_torch_forecast_exits_when_forecast_turns_negative(make_settings, tmp_path) -> None: def test_torch_forecast_exits_when_forecast_turns_negative(make_settings, tmp_path) -> None:
settings = make_settings(tmp_path, strategy_mode="torch_forecast", stop_loss_percent=0.04) settings = make_settings(tmp_path, strategy_mode="torch_forecast", stop_loss_percent=0.04)
strategy = SpotStrategy(settings) strategy = SpotStrategy(settings)
@@ -416,6 +644,74 @@ def test_torch_forecast_exits_when_forecast_turns_negative(make_settings, tmp_pa
assert "torch_forecast" in signal.reason assert "torch_forecast" in signal.reason
def test_torch_forecast_rebound_fallback_holds_without_model(make_settings, tmp_path) -> None:
settings = make_settings(tmp_path, strategy_mode="torch_forecast", min_hold_seconds=180)
strategy = SpotStrategy(settings)
position = Position(
1,
"XRPUSDT",
5,
1.0,
5.0,
0.005,
0.96,
1.035,
1.0,
entry_diagnostics={"entry_path": "rebound_fallback", "edge_mode": "rebound_fallback"},
)
ticker = Ticker("XRPUSDT", 1.001, 1.0009, 1.0011, 10_000_000, 1000, 1.0)
signal = strategy.exit_signal(
position,
_trend_entry_candles(close=1.0, ema50=0.98),
ticker,
forecast={
"usable": False,
"model": "none",
"reason": "no valid PyTorch LSTM/GRU model for symbol",
"block_entry": False,
},
)
assert signal.action == "HOLD"
assert signal.diagnostics["rebound_fallback_position"] is True
assert "rebound fallback" in signal.reason
def test_torch_forecast_rebound_fallback_still_sells_take_profit(make_settings, tmp_path) -> None:
settings = make_settings(tmp_path, strategy_mode="torch_forecast")
strategy = SpotStrategy(settings)
position = Position(
1,
"XRPUSDT",
5,
1.0,
5.0,
0.005,
0.96,
1.035,
1.04,
opened_at=utc_now() - timedelta(seconds=600),
entry_diagnostics={"entry_path": "rebound_fallback", "edge_mode": "rebound_fallback"},
)
ticker = Ticker("XRPUSDT", 1.036, 1.0359, 1.0361, 10_000_000, 1000, 1.0)
signal = strategy.exit_signal(
position,
_trend_entry_candles(close=1.0, ema50=0.98),
ticker,
forecast={
"usable": False,
"model": "none",
"reason": "no valid PyTorch LSTM/GRU model for symbol",
"block_entry": False,
},
)
assert signal.action == "SELL"
assert "take-profit" in signal.reason
def test_strategy_emits_buy_when_score_passes_threshold(make_settings, tmp_path) -> None: def test_strategy_emits_buy_when_score_passes_threshold(make_settings, tmp_path) -> None:
settings = make_settings(tmp_path) settings = make_settings(tmp_path)
strategy = SpotStrategy(settings) strategy = SpotStrategy(settings)