Use Torch forecast as primary strategy
This commit is contained in:
+2
-2
@@ -11,7 +11,7 @@ AUTO_SELECT_SYMBOLS=false
|
|||||||
TOP_SYMBOLS_COUNT=3
|
TOP_SYMBOLS_COUNT=3
|
||||||
SYMBOLS=BTCUSDT,ETHUSDT,SOLUSDT
|
SYMBOLS=BTCUSDT,ETHUSDT,SOLUSDT
|
||||||
|
|
||||||
STRATEGY_MODE=trend_macd
|
STRATEGY_MODE=torch_forecast
|
||||||
BASE_INTERVAL=60
|
BASE_INTERVAL=60
|
||||||
KLINE_LIMIT=240
|
KLINE_LIMIT=240
|
||||||
TREND_INTERVAL=D
|
TREND_INTERVAL=D
|
||||||
@@ -53,7 +53,7 @@ RISK_PER_TRADE_PERCENT=0.01
|
|||||||
ATR_TRAILING_MULTIPLIER=2.2
|
ATR_TRAILING_MULTIPLIER=2.2
|
||||||
TREND_RSI_MIN=45
|
TREND_RSI_MIN=45
|
||||||
TREND_RSI_MAX=65
|
TREND_RSI_MAX=65
|
||||||
TIME_SERIES_FORECAST_ENABLED=false
|
TIME_SERIES_FORECAST_ENABLED=true
|
||||||
TIME_SERIES_MIN_CANDLES=120
|
TIME_SERIES_MIN_CANDLES=120
|
||||||
TIME_SERIES_FORECAST_HORIZON=3
|
TIME_SERIES_FORECAST_HORIZON=3
|
||||||
TIME_SERIES_MIN_EDGE_PERCENT=0.04
|
TIME_SERIES_MIN_EDGE_PERCENT=0.04
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ Spot-бот для демо-торговли криптовалютой на р
|
|||||||
- Paper trading с учетом cash, комиссий, проскальзывания, stop-loss, take-profit и trailing stop.
|
- Paper trading с учетом cash, комиссий, проскальзывания, stop-loss, take-profit и trailing stop.
|
||||||
- Spot-only логика: покупка базовой монеты за USDT и продажа обратно, без short и без плеча.
|
- Spot-only логика: покупка базовой монеты за USDT и продажа обратно, без short и без плеча.
|
||||||
- Live spot-ордеры явно отправляются без плеча: `category=spot`, `isLeverage=0`.
|
- Live spot-ордеры явно отправляются без плеча: `category=spot`, `isLeverage=0`.
|
||||||
|
- Основная стратегия `torch_forecast`: входы и forecast-выходы идут только от экспортированной PyTorch LSTM/GRU модели; MACD/RSI/дневная EMA не являются условиями входа в этом режиме. Спред, ликвидность, stop-loss, ATR trailing stop, запрет DCA и лимиты экспозиции остаются защитой исполнения и риска.
|
||||||
- Основная стратегия `trend_macd`: вход на `1h`, дневной фильтр тренда на `1d`, long только если цена выше дневной EMA200 и дневная EMA50 выше EMA200.
|
- Основная стратегия `trend_macd`: вход на `1h`, дневной фильтр тренда на `1d`, long только если цена выше дневной EMA200 и дневная EMA50 выше EMA200.
|
||||||
- Вход `trend_macd`: MACD на `1h` пересекает signal вверх, цена выше EMA50, RSI в диапазоне `45..65`, спред и ликвидность проходят runtime-фильтры.
|
- Вход `trend_macd`: MACD на `1h` пересекает signal вверх, цена выше EMA50, RSI в диапазоне `45..65`, спред и ликвидность проходят runtime-фильтры.
|
||||||
- Выход `trend_macd`: MACD пересекает signal вниз, `1h` свеча закрылась ниже EMA50, сработал стоп `4%` или ATR trailing stop `2.2 ATR`.
|
- Выход `trend_macd`: MACD пересекает signal вниз, `1h` свеча закрылась ниже EMA50, сработал стоп `4%` или ATR trailing stop `2.2 ATR`.
|
||||||
@@ -72,7 +73,7 @@ Dashboard: <http://127.0.0.1:8787/>
|
|||||||
--epochs 60
|
--epochs 60
|
||||||
```
|
```
|
||||||
|
|
||||||
Файл из `TIME_SERIES_LSTM_MODEL_PATH` читается ботом автоматически, если `TIME_SERIES_FORECAST_ENABLED=true`. В основной стратегии `trend_macd` прогноз выключен по умолчанию и не влияет на входы/выходы. Экспортированные модели появляются в dashboard как `PyTorch LSTM` или `PyTorch GRU`; старый легкий reservoir LSTM-кандидат и все встроенные не-torch прогнозы удалены.
|
Файл из `TIME_SERIES_LSTM_MODEL_PATH` читается ботом автоматически, если `TIME_SERIES_FORECAST_ENABLED=true`. В стратегии `torch_forecast` экспортированная PyTorch LSTM/GRU модель является единственным направляющим сигналом для входа и forecast-выхода. Экспортированные модели появляются в dashboard как `PyTorch LSTM` или `PyTorch GRU`; старый легкий reservoir LSTM-кандидат и все встроенные не-torch прогнозы удалены.
|
||||||
|
|
||||||
Автопереобучение на Windows запускает PyTorch trainer, пишет лог в `runtime/torch_retrain.log` и защищается от параллельных запусков:
|
Автопереобучение на Windows запускает PyTorch trainer, пишет лог в `runtime/torch_retrain.log` и защищается от параллельных запусков:
|
||||||
|
|
||||||
@@ -103,7 +104,7 @@ STARTING_BALANCE_USDT=100
|
|||||||
AUTO_SELECT_SYMBOLS=false
|
AUTO_SELECT_SYMBOLS=false
|
||||||
TOP_SYMBOLS_COUNT=3
|
TOP_SYMBOLS_COUNT=3
|
||||||
SYMBOLS=BTCUSDT,ETHUSDT,SOLUSDT
|
SYMBOLS=BTCUSDT,ETHUSDT,SOLUSDT
|
||||||
STRATEGY_MODE=trend_macd
|
STRATEGY_MODE=torch_forecast
|
||||||
BASE_INTERVAL=60
|
BASE_INTERVAL=60
|
||||||
TREND_INTERVAL=D
|
TREND_INTERVAL=D
|
||||||
TREND_KLINE_LIMIT=260
|
TREND_KLINE_LIMIT=260
|
||||||
@@ -142,7 +143,7 @@ RISK_PER_TRADE_PERCENT=0.01
|
|||||||
ATR_TRAILING_MULTIPLIER=2.2
|
ATR_TRAILING_MULTIPLIER=2.2
|
||||||
TREND_RSI_MIN=45
|
TREND_RSI_MIN=45
|
||||||
TREND_RSI_MAX=65
|
TREND_RSI_MAX=65
|
||||||
TIME_SERIES_FORECAST_ENABLED=false
|
TIME_SERIES_FORECAST_ENABLED=true
|
||||||
TIME_SERIES_MIN_CANDLES=120
|
TIME_SERIES_MIN_CANDLES=120
|
||||||
TIME_SERIES_FORECAST_HORIZON=3
|
TIME_SERIES_FORECAST_HORIZON=3
|
||||||
TIME_SERIES_MIN_EDGE_PERCENT=0.04
|
TIME_SERIES_MIN_EDGE_PERCENT=0.04
|
||||||
|
|||||||
@@ -200,7 +200,7 @@ class CryptoSpotBot:
|
|||||||
return enriched
|
return enriched
|
||||||
|
|
||||||
def _close_paper_positions_outside_symbol_universe(self) -> None:
|
def _close_paper_positions_outside_symbol_universe(self) -> None:
|
||||||
if self.settings.strategy_mode != "trend_macd" or self.settings.trading_mode != "paper":
|
if self.settings.strategy_mode not in {"trend_macd", "torch_forecast"} or self.settings.trading_mode != "paper":
|
||||||
return
|
return
|
||||||
allowed_symbols = set(self.market.symbols or self.settings.symbols)
|
allowed_symbols = set(self.market.symbols or self.settings.symbols)
|
||||||
for position in list(self.broker.open_positions()):
|
for position in list(self.broker.open_positions()):
|
||||||
@@ -218,10 +218,10 @@ class CryptoSpotBot:
|
|||||||
self.broker.sell(
|
self.broker.sell(
|
||||||
position,
|
position,
|
||||||
synthetic_ticker,
|
synthetic_ticker,
|
||||||
"trend_macd: закрыта старая paper-позиция вне списка разрешенных пар",
|
f"{self.settings.strategy_mode}: закрыта старая paper-позиция вне списка разрешенных пар",
|
||||||
)
|
)
|
||||||
self.storage.event(
|
self.storage.event(
|
||||||
f"{position.symbol}: старая paper-позиция закрыта при переходе на trend_macd"
|
f"{position.symbol}: старая paper-позиция закрыта при переходе на {self.settings.strategy_mode}"
|
||||||
)
|
)
|
||||||
|
|
||||||
def _reduction_candidate_id(self, prices: dict[str, float]) -> int | None:
|
def _reduction_candidate_id(self, prices: dict[str, float]) -> int | None:
|
||||||
@@ -242,7 +242,7 @@ class CryptoSpotBot:
|
|||||||
return worst.id
|
return worst.id
|
||||||
|
|
||||||
def _update_patterns(self) -> None:
|
def _update_patterns(self) -> None:
|
||||||
if self.settings.strategy_mode == "trend_macd" or not self.settings.pattern_analysis_enabled:
|
if self.settings.strategy_mode in {"trend_macd", "torch_forecast"} or not self.settings.pattern_analysis_enabled:
|
||||||
self.market.patterns = {}
|
self.market.patterns = {}
|
||||||
return
|
return
|
||||||
patterns: dict[str, dict] = {}
|
patterns: dict[str, dict] = {}
|
||||||
@@ -256,8 +256,7 @@ class CryptoSpotBot:
|
|||||||
|
|
||||||
def _update_forecasts(self) -> None:
|
def _update_forecasts(self) -> None:
|
||||||
if (
|
if (
|
||||||
self.settings.strategy_mode == "trend_macd"
|
self.forecaster is None
|
||||||
or self.forecaster is None
|
|
||||||
or not self.settings.time_series_forecast_enabled
|
or not self.settings.time_series_forecast_enabled
|
||||||
):
|
):
|
||||||
self.market.forecasts = {}
|
self.market.forecasts = {}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
|
|
||||||
FIXED_SPOT_SYMBOLS = ("BTCUSDT", "ETHUSDT", "SOLUSDT")
|
FIXED_SPOT_SYMBOLS = ("BTCUSDT", "ETHUSDT", "SOLUSDT")
|
||||||
STRATEGY_MODES = {"legacy", "trend_macd"}
|
STRATEGY_MODES = {"legacy", "trend_macd", "torch_forecast"}
|
||||||
|
|
||||||
|
|
||||||
def _load_dotenv(path: Path) -> None:
|
def _load_dotenv(path: Path) -> None:
|
||||||
@@ -174,9 +174,17 @@ def load_settings(env_file: str | Path | None = None) -> Settings:
|
|||||||
mode = os.getenv("TRADING_MODE", "paper").strip().lower()
|
mode = os.getenv("TRADING_MODE", "paper").strip().lower()
|
||||||
if mode not in {"paper", "live"}:
|
if mode not in {"paper", "live"}:
|
||||||
raise ValueError("TRADING_MODE must be paper or live")
|
raise ValueError("TRADING_MODE must be paper or live")
|
||||||
strategy_mode = os.getenv("STRATEGY_MODE", "trend_macd").strip().lower()
|
strategy_mode = os.getenv("STRATEGY_MODE", "torch_forecast").strip().lower()
|
||||||
if strategy_mode not in STRATEGY_MODES:
|
if strategy_mode not in STRATEGY_MODES:
|
||||||
raise ValueError("STRATEGY_MODE must be legacy or trend_macd")
|
raise ValueError("STRATEGY_MODE must be legacy, trend_macd or torch_forecast")
|
||||||
|
auto_select_symbols = _bool_env("AUTO_SELECT_SYMBOLS", False)
|
||||||
|
top_symbols_count = _int_env("TOP_SYMBOLS_COUNT", len(FIXED_SPOT_SYMBOLS))
|
||||||
|
symbols = _symbols_env("SYMBOLS") or FIXED_SPOT_SYMBOLS
|
||||||
|
if strategy_mode == "torch_forecast":
|
||||||
|
auto_select_symbols = False
|
||||||
|
top_symbols_count = len(FIXED_SPOT_SYMBOLS)
|
||||||
|
symbols = FIXED_SPOT_SYMBOLS
|
||||||
|
forecast_enabled_default = strategy_mode == "torch_forecast"
|
||||||
settings = Settings(
|
settings = Settings(
|
||||||
trading_mode=mode,
|
trading_mode=mode,
|
||||||
host=os.getenv("HOST", "127.0.0.1"),
|
host=os.getenv("HOST", "127.0.0.1"),
|
||||||
@@ -185,9 +193,9 @@ def load_settings(env_file: str | Path | None = None) -> Settings:
|
|||||||
bybit_api_key=os.getenv("BYBIT_API_KEY", ""),
|
bybit_api_key=os.getenv("BYBIT_API_KEY", ""),
|
||||||
bybit_api_secret=os.getenv("BYBIT_API_SECRET", ""),
|
bybit_api_secret=os.getenv("BYBIT_API_SECRET", ""),
|
||||||
starting_balance_usdt=_float_env("STARTING_BALANCE_USDT", 100.0),
|
starting_balance_usdt=_float_env("STARTING_BALANCE_USDT", 100.0),
|
||||||
auto_select_symbols=_bool_env("AUTO_SELECT_SYMBOLS", False),
|
auto_select_symbols=auto_select_symbols,
|
||||||
top_symbols_count=_int_env("TOP_SYMBOLS_COUNT", len(FIXED_SPOT_SYMBOLS)),
|
top_symbols_count=top_symbols_count,
|
||||||
symbols=_symbols_env("SYMBOLS") or FIXED_SPOT_SYMBOLS,
|
symbols=symbols,
|
||||||
strategy_mode=strategy_mode,
|
strategy_mode=strategy_mode,
|
||||||
base_interval=os.getenv("BASE_INTERVAL", "60"),
|
base_interval=os.getenv("BASE_INTERVAL", "60"),
|
||||||
kline_limit=_int_env("KLINE_LIMIT", 240),
|
kline_limit=_int_env("KLINE_LIMIT", 240),
|
||||||
@@ -236,7 +244,7 @@ def load_settings(env_file: str | Path | None = None) -> Settings:
|
|||||||
atr_trailing_multiplier=_float_env("ATR_TRAILING_MULTIPLIER", 2.2),
|
atr_trailing_multiplier=_float_env("ATR_TRAILING_MULTIPLIER", 2.2),
|
||||||
trend_rsi_min=_float_env("TREND_RSI_MIN", 45.0),
|
trend_rsi_min=_float_env("TREND_RSI_MIN", 45.0),
|
||||||
trend_rsi_max=_float_env("TREND_RSI_MAX", 65.0),
|
trend_rsi_max=_float_env("TREND_RSI_MAX", 65.0),
|
||||||
time_series_forecast_enabled=_bool_env("TIME_SERIES_FORECAST_ENABLED", False),
|
time_series_forecast_enabled=_bool_env("TIME_SERIES_FORECAST_ENABLED", forecast_enabled_default),
|
||||||
time_series_min_candles=_int_env("TIME_SERIES_MIN_CANDLES", 120),
|
time_series_min_candles=_int_env("TIME_SERIES_MIN_CANDLES", 120),
|
||||||
time_series_forecast_horizon=_int_env("TIME_SERIES_FORECAST_HORIZON", 3),
|
time_series_forecast_horizon=_int_env("TIME_SERIES_FORECAST_HORIZON", 3),
|
||||||
time_series_min_edge_percent=_float_env("TIME_SERIES_MIN_EDGE_PERCENT", 0.04),
|
time_series_min_edge_percent=_float_env("TIME_SERIES_MIN_EDGE_PERCENT", 0.04),
|
||||||
|
|||||||
@@ -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 == "trend_macd" and len(self.positions_for_symbol(symbol)) >= 1:
|
if self.settings.strategy_mode in {"trend_macd", "torch_forecast"} and len(self.positions_for_symbol(symbol)) >= 1:
|
||||||
return False, "DCA/усреднение отключено: позиция по паре уже открыта"
|
return False, "DCA/усреднение отключено: позиция по паре уже открыта"
|
||||||
dynamic_pair_limit = max(
|
dynamic_pair_limit = max(
|
||||||
self.settings.max_positions_per_symbol,
|
self.settings.max_positions_per_symbol,
|
||||||
|
|||||||
@@ -24,6 +24,15 @@ class SpotStrategy:
|
|||||||
account: dict | None = None,
|
account: dict | None = None,
|
||||||
trend_candles: list[Candle] | None = None,
|
trend_candles: list[Candle] | None = None,
|
||||||
) -> Signal:
|
) -> Signal:
|
||||||
|
if self.settings.strategy_mode == "torch_forecast":
|
||||||
|
return _torch_forecast_entry_signal(
|
||||||
|
settings=self.settings,
|
||||||
|
symbol=symbol,
|
||||||
|
ticker=ticker,
|
||||||
|
open_positions_for_symbol=open_positions_for_symbol,
|
||||||
|
forecast=forecast or {},
|
||||||
|
account=account,
|
||||||
|
)
|
||||||
if self.settings.strategy_mode == "trend_macd":
|
if self.settings.strategy_mode == "trend_macd":
|
||||||
return _trend_macd_entry_signal(
|
return _trend_macd_entry_signal(
|
||||||
settings=self.settings,
|
settings=self.settings,
|
||||||
@@ -354,6 +363,8 @@ class SpotStrategy:
|
|||||||
learning: dict | None = None,
|
learning: dict | None = None,
|
||||||
forecast: dict | None = None,
|
forecast: dict | None = None,
|
||||||
) -> Signal:
|
) -> Signal:
|
||||||
|
if self.settings.strategy_mode == "torch_forecast":
|
||||||
|
return _torch_forecast_exit_signal(self.settings, position, candles, ticker, forecast or {})
|
||||||
if self.settings.strategy_mode == "trend_macd":
|
if self.settings.strategy_mode == "trend_macd":
|
||||||
return _trend_macd_exit_signal(self.settings, position, candles, ticker)
|
return _trend_macd_exit_signal(self.settings, position, candles, ticker)
|
||||||
if ticker is None:
|
if ticker is None:
|
||||||
@@ -600,6 +611,208 @@ def _trend_macd_exit_signal(
|
|||||||
return Signal(position.symbol, "HOLD", 0.35, "trend_macd: условия выхода не выполнены", diagnostics)
|
return Signal(position.symbol, "HOLD", 0.35, "trend_macd: условия выхода не выполнены", diagnostics)
|
||||||
|
|
||||||
|
|
||||||
|
def _torch_forecast_entry_signal(
|
||||||
|
*,
|
||||||
|
settings: Settings,
|
||||||
|
symbol: str,
|
||||||
|
ticker: Ticker | None,
|
||||||
|
open_positions_for_symbol: int,
|
||||||
|
forecast: dict,
|
||||||
|
account: dict | None,
|
||||||
|
) -> Signal:
|
||||||
|
if ticker is None:
|
||||||
|
return Signal(symbol, "HOLD", 0.0, "torch_forecast: no ticker data")
|
||||||
|
if open_positions_for_symbol > 0:
|
||||||
|
return Signal(symbol, "HOLD", 0.0, "torch_forecast: position for symbol is already open")
|
||||||
|
|
||||||
|
stop_loss_percent = _clamp(settings.stop_loss_percent, 0.003, 0.08)
|
||||||
|
sizing = _torch_forecast_position_sizing(settings, account, stop_loss_percent, forecast)
|
||||||
|
position_notional = float(sizing["notional_usdt"])
|
||||||
|
expected_return = _safe_float(forecast.get("expected_return_percent"), 0.0)
|
||||||
|
probability_up = _safe_float(forecast.get("probability_up"), 0.5)
|
||||||
|
skill = _safe_float(forecast.get("skill"), 0.0)
|
||||||
|
min_edge = max(0.0, settings.time_series_min_edge_percent)
|
||||||
|
min_probability = _torch_min_probability(settings)
|
||||||
|
confidence = _torch_forecast_confidence(settings, forecast)
|
||||||
|
spread_ok = ticker.spread_percent <= settings.max_spread_percent
|
||||||
|
liquidity_ok = ticker.turnover_24h >= settings.min_24h_turnover_usdt
|
||||||
|
model_ok = _is_torch_forecast(forecast)
|
||||||
|
checks = {
|
||||||
|
"torch_model_ok": model_ok,
|
||||||
|
"forecast_usable": bool(forecast.get("usable", False)),
|
||||||
|
"forecast_not_blocked": not bool(forecast.get("block_entry", False)),
|
||||||
|
"expected_edge_ok": expected_return >= min_edge,
|
||||||
|
"probability_ok": probability_up >= min_probability,
|
||||||
|
"skill_ok": skill > 0.0,
|
||||||
|
"confidence_ok": confidence >= settings.min_signal_confidence,
|
||||||
|
"spread_ok": spread_ok,
|
||||||
|
"liquidity_ok": liquidity_ok,
|
||||||
|
"risk_size_ok": position_notional >= settings.min_position_usdt,
|
||||||
|
}
|
||||||
|
diagnostics = {
|
||||||
|
"strategy_mode": "torch_forecast",
|
||||||
|
"trade_mode": "TORCH_FORECAST",
|
||||||
|
"forecast": forecast,
|
||||||
|
"position_notional_usdt": position_notional,
|
||||||
|
"position_sizing": sizing,
|
||||||
|
"stop_loss_percent": stop_loss_percent,
|
||||||
|
"atr_trailing_multiplier": _clamp(settings.atr_trailing_multiplier, 0.5, 10.0),
|
||||||
|
"expected_return_percent": expected_return,
|
||||||
|
"min_edge_percent": min_edge,
|
||||||
|
"probability_up": probability_up,
|
||||||
|
"min_probability_up": min_probability,
|
||||||
|
"skill": skill,
|
||||||
|
"spread_percent": round(ticker.spread_percent, 5),
|
||||||
|
"turnover_24h": ticker.turnover_24h,
|
||||||
|
"checks": checks,
|
||||||
|
"grid": {"enabled": False, "active": False},
|
||||||
|
"rebound": {"enabled": False, "active": False},
|
||||||
|
"learning": {},
|
||||||
|
"llm": {},
|
||||||
|
}
|
||||||
|
if all(checks.values()):
|
||||||
|
return Signal(
|
||||||
|
symbol,
|
||||||
|
"BUY",
|
||||||
|
confidence,
|
||||||
|
(
|
||||||
|
"torch_forecast: PyTorch edge confirmed; "
|
||||||
|
f"model={forecast.get('model')}, p_up={probability_up:.3f}, "
|
||||||
|
f"expected={expected_return:.4f}%, size={position_notional:.2f} USDT"
|
||||||
|
),
|
||||||
|
diagnostics,
|
||||||
|
)
|
||||||
|
failed = ", ".join(name for name, ok in checks.items() if not ok)
|
||||||
|
return Signal(symbol, "HOLD", confidence, f"torch_forecast: entry blocked ({failed})", diagnostics)
|
||||||
|
|
||||||
|
|
||||||
|
def _torch_forecast_exit_signal(
|
||||||
|
settings: Settings,
|
||||||
|
position: Position,
|
||||||
|
candles: list[Candle],
|
||||||
|
ticker: Ticker | None,
|
||||||
|
forecast: dict,
|
||||||
|
) -> Signal:
|
||||||
|
if ticker is None:
|
||||||
|
return Signal(position.symbol, "HOLD", 0.0, "torch_forecast: no ticker data for exit")
|
||||||
|
|
||||||
|
latest = candles[-1] if candles else None
|
||||||
|
price = ticker.last_price
|
||||||
|
stop_loss_percent = _clamp(settings.stop_loss_percent, 0.003, 0.08)
|
||||||
|
effective_stop_loss = max(position.stop_loss, position.entry_price * (1 - stop_loss_percent))
|
||||||
|
atr_multiplier = _clamp(settings.atr_trailing_multiplier, 0.5, 10.0)
|
||||||
|
atr_trailing_stop = None
|
||||||
|
if latest and latest.atr_14 is not None and position.highest_price > position.entry_price:
|
||||||
|
atr_trailing_stop = max(effective_stop_loss, position.highest_price - latest.atr_14 * atr_multiplier)
|
||||||
|
|
||||||
|
expected_return = _safe_float(forecast.get("expected_return_percent"), 0.0)
|
||||||
|
probability_up = _safe_float(forecast.get("probability_up"), 0.5)
|
||||||
|
skill = _safe_float(forecast.get("skill"), 0.0)
|
||||||
|
min_edge = max(0.0, settings.time_series_min_edge_percent)
|
||||||
|
min_probability = _torch_min_probability(settings)
|
||||||
|
estimated_exit_net_percent = _estimated_exit_net_percent(position, price, settings)
|
||||||
|
diagnostics = {
|
||||||
|
"strategy_mode": "torch_forecast",
|
||||||
|
"price": price,
|
||||||
|
"entry_price": position.entry_price,
|
||||||
|
"stop_loss": effective_stop_loss,
|
||||||
|
"atr_trailing_stop": atr_trailing_stop,
|
||||||
|
"atr_trailing_multiplier": atr_multiplier,
|
||||||
|
"highest_price": position.highest_price,
|
||||||
|
"forecast": forecast,
|
||||||
|
"expected_return_percent": expected_return,
|
||||||
|
"min_edge_percent": min_edge,
|
||||||
|
"probability_up": probability_up,
|
||||||
|
"min_probability_up": min_probability,
|
||||||
|
"skill": skill,
|
||||||
|
"estimated_exit_net_percent": round(estimated_exit_net_percent, 4),
|
||||||
|
"atr_14": latest.atr_14 if latest else None,
|
||||||
|
}
|
||||||
|
if price <= effective_stop_loss:
|
||||||
|
return Signal(position.symbol, "SELL", 1.0, "torch_forecast: stop-loss hit", diagnostics)
|
||||||
|
if atr_trailing_stop is not None and price <= atr_trailing_stop:
|
||||||
|
return Signal(position.symbol, "SELL", 0.94, "torch_forecast: ATR trailing stop hit", diagnostics)
|
||||||
|
if not _is_torch_forecast(forecast):
|
||||||
|
return Signal(position.symbol, "SELL", 0.78, "torch_forecast: no valid PyTorch forecast to hold", diagnostics)
|
||||||
|
if bool(forecast.get("block_entry", False)) or expected_return <= 0.0 or probability_up <= 0.50:
|
||||||
|
return Signal(
|
||||||
|
position.symbol,
|
||||||
|
"SELL",
|
||||||
|
0.86,
|
||||||
|
(
|
||||||
|
"torch_forecast: PyTorch forecast turned negative; "
|
||||||
|
f"p_up={probability_up:.3f}, expected={expected_return:.4f}%"
|
||||||
|
),
|
||||||
|
diagnostics,
|
||||||
|
)
|
||||||
|
weak_hold = expected_return < min_edge or probability_up < min_probability or skill <= 0.0
|
||||||
|
if weak_hold and estimated_exit_net_percent >= 0:
|
||||||
|
return Signal(
|
||||||
|
position.symbol,
|
||||||
|
"SELL",
|
||||||
|
0.74,
|
||||||
|
(
|
||||||
|
"torch_forecast: PyTorch no longer confirms enough edge; "
|
||||||
|
f"p_up={probability_up:.3f}, expected={expected_return:.4f}%"
|
||||||
|
),
|
||||||
|
diagnostics,
|
||||||
|
)
|
||||||
|
return Signal(position.symbol, "HOLD", 0.35, "torch_forecast: PyTorch hold confirmed", diagnostics)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_torch_forecast(forecast: dict) -> bool:
|
||||||
|
model = str(forecast.get("model", "")).strip().lower()
|
||||||
|
return bool(forecast.get("usable", False)) and model in {"torch_lstm", "torch_gru"}
|
||||||
|
|
||||||
|
|
||||||
|
def _torch_min_probability(settings: Settings) -> float:
|
||||||
|
return round(_clamp(settings.min_signal_confidence - 0.08, 0.52, 0.68), 4)
|
||||||
|
|
||||||
|
|
||||||
|
def _torch_forecast_confidence(settings: Settings, forecast: dict) -> float:
|
||||||
|
expected_return = max(0.0, _safe_float(forecast.get("expected_return_percent"), 0.0))
|
||||||
|
probability_up = _safe_float(forecast.get("probability_up"), 0.5)
|
||||||
|
skill = max(0.0, _safe_float(forecast.get("skill"), 0.0))
|
||||||
|
min_edge = max(0.01, settings.time_series_min_edge_percent)
|
||||||
|
edge_strength = _clamp(expected_return / max(min_edge * 4.0, 0.01), 0.0, 1.0)
|
||||||
|
probability_strength = _clamp((probability_up - 0.50) / 0.25, 0.0, 1.0)
|
||||||
|
skill_strength = _clamp(skill / 0.35, 0.0, 1.0)
|
||||||
|
confidence = 0.45 + probability_strength * 0.30 + edge_strength * 0.20 + skill_strength * 0.10
|
||||||
|
return round(_clamp(confidence, 0.0, 0.96), 4)
|
||||||
|
|
||||||
|
|
||||||
|
def _torch_forecast_position_sizing(
|
||||||
|
settings: Settings,
|
||||||
|
account: dict | None,
|
||||||
|
stop_loss_percent: float,
|
||||||
|
forecast: dict,
|
||||||
|
) -> dict[str, float | str]:
|
||||||
|
base = _trend_position_sizing(settings, account, stop_loss_percent)
|
||||||
|
base_notional = float(base["notional_usdt"])
|
||||||
|
if base_notional <= 0:
|
||||||
|
notional = 0.0
|
||||||
|
edge_multiplier = probability_multiplier = skill_multiplier = 0.0
|
||||||
|
else:
|
||||||
|
expected_return = max(0.0, _safe_float(forecast.get("expected_return_percent"), 0.0))
|
||||||
|
probability_up = _safe_float(forecast.get("probability_up"), 0.5)
|
||||||
|
skill = max(0.0, _safe_float(forecast.get("skill"), 0.0))
|
||||||
|
min_edge = max(0.01, settings.time_series_min_edge_percent)
|
||||||
|
edge_multiplier = _clamp(expected_return / max(min_edge * 3.0, 0.01), 0.25, 1.15)
|
||||||
|
probability_multiplier = _clamp(0.75 + (probability_up - 0.55) * 3.0, 0.50, 1.20)
|
||||||
|
skill_multiplier = _clamp(0.85 + skill * 0.60, 0.60, 1.15)
|
||||||
|
raw = base_notional * edge_multiplier * probability_multiplier * skill_multiplier
|
||||||
|
notional = 0.0 if raw < settings.min_position_usdt else min(raw, settings.max_position_usdt)
|
||||||
|
return {
|
||||||
|
**base,
|
||||||
|
"method": "torch_forecast_risk",
|
||||||
|
"notional_usdt": round(notional, 2),
|
||||||
|
"base_notional_usdt": base["notional_usdt"],
|
||||||
|
"torch_edge_multiplier": round(edge_multiplier, 4),
|
||||||
|
"torch_probability_multiplier": round(probability_multiplier, 4),
|
||||||
|
"torch_skill_multiplier": round(skill_multiplier, 4),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _has_trend_entry_indicators(current: Candle, previous: Candle, trend: Candle) -> bool:
|
def _has_trend_entry_indicators(current: Candle, previous: Candle, trend: Candle) -> bool:
|
||||||
return all(
|
return all(
|
||||||
value is not None
|
value is not None
|
||||||
|
|||||||
+34
-1
@@ -77,7 +77,40 @@ def test_default_symbols_are_fixed_trend_pairs(tmp_path, monkeypatch) -> None:
|
|||||||
assert settings.auto_select_symbols is False
|
assert settings.auto_select_symbols is False
|
||||||
assert settings.top_symbols_count == 3
|
assert settings.top_symbols_count == 3
|
||||||
assert settings.symbols == FIXED_SPOT_SYMBOLS
|
assert settings.symbols == FIXED_SPOT_SYMBOLS
|
||||||
assert settings.strategy_mode == "trend_macd"
|
assert settings.strategy_mode == "torch_forecast"
|
||||||
assert settings.base_interval == "60"
|
assert settings.base_interval == "60"
|
||||||
assert settings.trend_interval == "D"
|
assert settings.trend_interval == "D"
|
||||||
assert settings.risk_per_trade_percent == 0.01
|
assert settings.risk_per_trade_percent == 0.01
|
||||||
|
assert settings.time_series_forecast_enabled is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_torch_forecast_forces_fixed_three_symbols(tmp_path, monkeypatch) -> None:
|
||||||
|
for key in (
|
||||||
|
"AUTO_SELECT_SYMBOLS",
|
||||||
|
"TOP_SYMBOLS_COUNT",
|
||||||
|
"SYMBOLS",
|
||||||
|
"STRATEGY_MODE",
|
||||||
|
"TIME_SERIES_FORECAST_ENABLED",
|
||||||
|
):
|
||||||
|
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=9",
|
||||||
|
"SYMBOLS=DOGEUSDT,XRPUSDT",
|
||||||
|
]
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
settings = load_settings(env_file)
|
||||||
|
|
||||||
|
assert settings.auto_select_symbols is False
|
||||||
|
assert settings.top_symbols_count == 3
|
||||||
|
assert settings.symbols == FIXED_SPOT_SYMBOLS
|
||||||
|
assert settings.time_series_forecast_enabled is True
|
||||||
|
|||||||
@@ -160,6 +160,31 @@ 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:
|
||||||
|
settings = make_settings(
|
||||||
|
tmp_path,
|
||||||
|
strategy_mode="torch_forecast",
|
||||||
|
min_position_usdt=1,
|
||||||
|
max_position_usdt=20,
|
||||||
|
max_symbol_exposure_usdt=20,
|
||||||
|
max_total_exposure_usdt=80,
|
||||||
|
max_open_positions=3,
|
||||||
|
max_positions_per_symbol=20,
|
||||||
|
max_entries_per_minute=0,
|
||||||
|
)
|
||||||
|
storage = Storage(settings.database_path)
|
||||||
|
broker = PaperBroker(settings, storage)
|
||||||
|
ticker = Ticker("BTCUSDT", 100, 99.9, 100.1, 10_000_000, 100, 0)
|
||||||
|
instrument = Instrument("BTCUSDT", "BTC", "USDT", "Trading", 0.01, 0.000001, 0.000001, 1)
|
||||||
|
|
||||||
|
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})
|
||||||
|
|
||||||
|
assert first is not None
|
||||||
|
assert second is None
|
||||||
|
assert len(broker.open_positions()) == 1
|
||||||
|
|
||||||
|
|
||||||
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:
|
||||||
settings = make_settings(
|
settings = make_settings(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
|
|||||||
@@ -233,6 +233,81 @@ def test_trend_macd_exits_on_atr_trailing_stop(make_settings, tmp_path) -> None:
|
|||||||
assert "ATR trailing" in signal.reason
|
assert "ATR trailing" in signal.reason
|
||||||
|
|
||||||
|
|
||||||
|
def test_torch_forecast_buys_only_from_positive_torch_edge(make_settings, tmp_path) -> None:
|
||||||
|
settings = make_settings(
|
||||||
|
tmp_path,
|
||||||
|
strategy_mode="torch_forecast",
|
||||||
|
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)
|
||||||
|
|
||||||
|
signal = strategy.entry_signal(
|
||||||
|
"BTCUSDT",
|
||||||
|
[],
|
||||||
|
ticker,
|
||||||
|
open_positions_for_symbol=0,
|
||||||
|
forecast={
|
||||||
|
"usable": True,
|
||||||
|
"model": "torch_gru",
|
||||||
|
"expected_return_percent": 0.24,
|
||||||
|
"probability_up": 0.63,
|
||||||
|
"skill": 0.22,
|
||||||
|
"block_entry": False,
|
||||||
|
},
|
||||||
|
account={"equity": 100.0},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert signal.action == "BUY"
|
||||||
|
assert signal.diagnostics["strategy_mode"] == "torch_forecast"
|
||||||
|
assert signal.diagnostics["checks"]["torch_model_ok"] is True
|
||||||
|
assert signal.diagnostics["position_notional_usdt"] == 25.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_torch_forecast_blocks_without_valid_torch_model(make_settings, tmp_path) -> None:
|
||||||
|
settings = make_settings(tmp_path, strategy_mode="torch_forecast")
|
||||||
|
strategy = SpotStrategy(settings)
|
||||||
|
ticker = Ticker("ETHUSDT", 105, 104.99, 105.01, 10_000_000, 1000, 1.0)
|
||||||
|
|
||||||
|
signal = strategy.entry_signal(
|
||||||
|
"ETHUSDT",
|
||||||
|
_trend_entry_candles(),
|
||||||
|
ticker,
|
||||||
|
open_positions_for_symbol=0,
|
||||||
|
forecast={"usable": True, "model": "none", "expected_return_percent": 0.5, "probability_up": 0.7},
|
||||||
|
account={"equity": 100.0},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert signal.action == "HOLD"
|
||||||
|
assert signal.diagnostics["checks"]["torch_model_ok"] is False
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
strategy = SpotStrategy(settings)
|
||||||
|
position = Position(1, "SOLUSDT", 1, 100, 100, 0.1, 96, 120, 103)
|
||||||
|
ticker = Ticker("SOLUSDT", 101, 100.99, 101.01, 10_000_000, 1000, 1.0)
|
||||||
|
|
||||||
|
signal = strategy.exit_signal(
|
||||||
|
position,
|
||||||
|
_trend_entry_candles(),
|
||||||
|
ticker,
|
||||||
|
forecast={
|
||||||
|
"usable": True,
|
||||||
|
"model": "torch_lstm",
|
||||||
|
"expected_return_percent": -0.08,
|
||||||
|
"probability_up": 0.43,
|
||||||
|
"skill": 0.18,
|
||||||
|
"block_entry": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert signal.action == "SELL"
|
||||||
|
assert "torch_forecast" 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