Train Torch model for 12 spot pairs
This commit is contained in:
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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] = {}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
+1160321
-308065
File diff suppressed because it is too large
Load Diff
+1160321
-308065
File diff suppressed because it is too large
Load Diff
+1875
-1221
File diff suppressed because it is too large
Load Diff
+1474
-937
File diff suppressed because it is too large
Load Diff
+1875
-1221
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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 == ()
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user