diff --git a/README.md b/README.md
index 66ddc50..5023783 100644
--- a/README.md
+++ b/README.md
@@ -65,17 +65,19 @@ Dashboard:
```powershell
.\.venv\Scripts\python.exe -m pip install torch --index-url https://download.pytorch.org/whl/cpu
.\.venv\Scripts\python.exe tools\train_torch_recurrent_forecaster.py `
- --limit 1000 `
+ --limit 3000 `
--architectures lstm,gru `
- --lookbacks 32,64 `
- --hidden-sizes 32,64 `
+ --lookbacks 64 `
+ --hidden-sizes 64,96 `
--layers 2 `
--dropouts 0.15 `
--horizon 3 `
- --epochs 60
+ --horizons 1,3,6,12 `
+ --context-symbols BTCUSDT,ETHUSDT `
+ --epochs 70
```
-Новый artifact версии 3 обучается как multifeature direct-horizon модель: вход `input_size=26` включает доходности, форму свечи, объем, ATR%, RSI, MACD histogram, расстояние до EMA50/EMA200 и числовые признаки текущего шаблона пары: score, bullish/bearish/range, pullback, reversal, stabilized drop, breakout/breakdown, fast drop, volume spike и позицию цены в 20-свечном диапазоне. Цель обучается сразу на горизонт `TIME_SERIES_FORECAST_HORIZON`, без умножения one-step прогноза.
+Новый artifact версии 4 обучается как probabilistic multi-horizon модель: вход включает доходности, форму свечи, объем, ATR%, realized volatility, RSI/MACD/EMA slopes, 4h/24h rolling trend, дневные EMA-признаки, BTC/ETH cross-asset признаки и числовые признаки текущего шаблона пары. Цель обучается как `future log return - комиссии - проскальзывание`, нормализованная на текущую волатильность. Модель сразу прогнозирует горизонты `1/3/6/12`, quantile-оценки `q10/q50/q90` и `P(up)`.
Файл из `TIME_SERIES_LSTM_MODEL_PATH` читается ботом автоматически, если `TIME_SERIES_FORECAST_ENABLED=true`. В стратегии `torch_forecast` экспортированная PyTorch LSTM/GRU модель является единственным направляющим сигналом для входа и forecast-выхода. Экспортированные модели появляются в dashboard как `PyTorch LSTM` или `PyTorch GRU`; старый легкий reservoir LSTM-кандидат и все встроенные не-torch прогнозы удалены.
@@ -86,9 +88,9 @@ powershell -ExecutionPolicy Bypass -File tools\run_torch_retrain.ps1
powershell -ExecutionPolicy Bypass -File tools\install_windows_torch_retrainer.ps1
```
-По умолчанию Windows-расписание переобучает PyTorch `LSTM/GRU` каждые 6 часов с `--limit 1000` на парах `BTCUSDT,ETHUSDT,SOLUSDT,LTCUSDT`. Параметры можно переопределить через env: `TORCH_RETRAIN_SYMBOLS`, `TORCH_RETRAIN_LIMIT`, `TORCH_RETRAIN_LOOKBACKS`, `TORCH_RETRAIN_ARCHITECTURES`, `TORCH_RETRAIN_HIDDEN_SIZES`, `TORCH_RETRAIN_LAYERS`, `TORCH_RETRAIN_DROPOUTS`, `TORCH_RETRAIN_EPOCHS`, `TORCH_RETRAIN_PATIENCE`, `TORCH_RETRAIN_INTERVAL`, `TORCH_RETRAIN_ENV`.
+По умолчанию Windows-расписание переобучает PyTorch `LSTM/GRU` каждые 6 часов с `--limit 3000` на парах `BTCUSDT,ETHUSDT,SOLUSDT,LTCUSDT`. Параметры можно переопределить через env: `TORCH_RETRAIN_SYMBOLS`, `TORCH_RETRAIN_LIMIT`, `TORCH_RETRAIN_LOOKBACKS`, `TORCH_RETRAIN_ARCHITECTURES`, `TORCH_RETRAIN_HIDDEN_SIZES`, `TORCH_RETRAIN_LAYERS`, `TORCH_RETRAIN_DROPOUTS`, `TORCH_RETRAIN_HORIZON`, `TORCH_RETRAIN_HORIZONS`, `TORCH_RETRAIN_CONTEXT_SYMBOLS`, `TORCH_RETRAIN_FEATURES`, `TORCH_RETRAIN_EPOCHS`, `TORCH_RETRAIN_PATIENCE`, `TORCH_RETRAIN_INTERVAL`, `TORCH_RETRAIN_ENV`.
-Дополнительно для нового multifeature trainer доступны env-переменные `TORCH_RETRAIN_HORIZON` и `TORCH_RETRAIN_FEATURES`.
+Внутри recurrent модели используются exportable attention pooling и LayerNorm перед forecast-head; Raspberry Pi по-прежнему исполняет модель из JSON без PyTorch runtime.
## Docker
diff --git a/crypto_spot_bot/bot.py b/crypto_spot_bot/bot.py
index 881e409..cc3bd47 100644
--- a/crypto_spot_bot/bot.py
+++ b/crypto_spot_bot/bot.py
@@ -266,6 +266,8 @@ class CryptoSpotBot:
forecasts[symbol] = self.forecaster.forecast(
self.market.candles.get(symbol, []),
symbol=symbol,
+ market_candles=self.market.candles,
+ trend_candles=self.market.trend_candles.get(symbol, []),
).as_dict()
self.market.forecasts = forecasts
diff --git a/crypto_spot_bot/dashboard.py b/crypto_spot_bot/dashboard.py
index 12a7709..2651d34 100644
--- a/crypto_spot_bot/dashboard.py
+++ b/crypto_spot_bot/dashboard.py
@@ -839,8 +839,10 @@ HTML = r"""
}
return `
Модель${escapeHtml(modelName(forecast.model || '-'))}
+
Горизонт${num(forecast.horizon || 0, 0)}ч
P роста${num((forecast.probability_up || 0) * 100, 1)}%
Ожидание${signedNum(forecast.expected_return_percent, 3)}%
+
Q10/Q50/Q90${signedNum(forecast.quantile_10_percent, 2)} / ${signedNum(forecast.quantile_50_percent, 2)} / ${signedNum(forecast.quantile_90_percent, 2)}%
Волат.${num(forecast.volatility_percent, 3)}%
`;
}
diff --git a/crypto_spot_bot/time_series.py b/crypto_spot_bot/time_series.py
index 4b1a2f9..687944b 100644
--- a/crypto_spot_bot/time_series.py
+++ b/crypto_spot_bot/time_series.py
@@ -2,6 +2,7 @@ from __future__ import annotations
import json
import math
+from bisect import bisect_right
from dataclasses import asdict, dataclass, field
from typing import Any
@@ -13,17 +14,46 @@ DEFAULT_TORCH_FEATURES = (
"return_1",
"return_3",
"return_6",
+ "return_12",
+ "return_24",
"range_percent",
"body_percent",
"upper_wick_percent",
"lower_wick_percent",
"volume_change",
"volume_ratio",
+ "volume_percentile_20",
"atr_percent",
+ "atr_ratio_20",
+ "realized_volatility_12",
+ "realized_volatility_24",
"rsi_centered",
+ "rsi_slope_6",
"macd_hist_percent",
+ "macd_hist_slope_3",
"ema50_gap_percent",
"ema200_gap_percent",
+ "ema20_slope_6",
+ "ema50_slope_12",
+ "ema200_slope_24",
+ "ema50_ema200_gap_percent",
+ "range_position_50",
+ "trend_return_4h",
+ "trend_return_24h",
+ "daily_close_ema200_gap_percent",
+ "daily_ema50_ema200_gap_percent",
+ "daily_ema50_slope",
+ "btc_return_1",
+ "btc_return_3",
+ "btc_return_6",
+ "btc_return_24",
+ "eth_return_1",
+ "eth_return_3",
+ "eth_return_6",
+ "eth_return_24",
+ "relative_btc_return_3",
+ "relative_eth_return_3",
+ "btc_eth_return_spread_3",
"pattern_score",
"pattern_bullish",
"pattern_bearish",
@@ -56,6 +86,13 @@ class TimeSeriesForecast:
skill: float
horizon: int
reason: str
+ expected_gross_return_percent: float
+ quantile_10_percent: float
+ quantile_50_percent: float
+ quantile_90_percent: float
+ conservative_return_percent: float
+ target_transform: str
+ horizon_forecasts: dict[str, Any] = field(default_factory=dict)
candidates: list[dict[str, Any]] = field(default_factory=list)
def as_dict(self) -> dict[str, Any]:
@@ -68,7 +105,14 @@ class TimeSeriesForecaster:
self._lstm_artifact_mtime: float | None = None
self._lstm_artifact: dict[str, Any] = {}
- def forecast(self, candles: list[Candle], symbol: str | None = None) -> TimeSeriesForecast:
+ def forecast(
+ self,
+ candles: list[Candle],
+ symbol: str | None = None,
+ *,
+ market_candles: dict[str, list[Candle]] | None = None,
+ trend_candles: list[Candle] | None = None,
+ ) -> TimeSeriesForecast:
if not self.settings.time_series_forecast_enabled:
return _empty_forecast(False, "time-series forecast is disabled")
closes = [float(candle.close) for candle in candles if candle.close > 0]
@@ -82,7 +126,17 @@ class TimeSeriesForecaster:
artifact = self._load_lstm_artifact()
entry = _torch_recurrent_entry(symbol, artifact)
model = _torch_recurrent_model_name(symbol, artifact)
- feature_rows = _feature_matrix(candles, _feature_names(entry)) if entry else []
+ feature_rows = (
+ _feature_matrix(
+ candles,
+ _feature_names(entry),
+ symbol=symbol,
+ market_candles=market_candles,
+ trend_candles=trend_candles,
+ )
+ if entry
+ else []
+ )
if not model or not _can_use_torch_recurrent(returns, symbol, artifact, feature_rows):
return _empty_forecast(True, "no valid PyTorch LSTM/GRU model for symbol")
@@ -92,10 +146,79 @@ class TimeSeriesForecaster:
artifact,
feature_rows=feature_rows,
closes=closes,
+ candles=candles,
)
if entry is None or prediction is None:
return _empty_forecast(True, "PyTorch LSTM/GRU model could not build a forecast")
+ if isinstance(prediction, dict):
+ selected = _select_horizon_prediction(
+ prediction,
+ _entry_horizon(entry, self.settings.time_series_forecast_horizon),
+ )
+ if not selected:
+ return _empty_forecast(True, "PyTorch LSTM/GRU model could not select a forecast horizon")
+ expected_return = float(selected["expected_return"])
+ expected_gross_return = float(selected.get("expected_gross_return", expected_return))
+ expected_price = closes[-1] * math.exp(expected_gross_return)
+ probability_up = _clamp(float(selected.get("probability_up", 0.5)), 0.0, 1.0)
+ model_mae = max(float(selected.get("validation_mae", 0.0)), 1e-9)
+ baseline_mae = max(float(selected.get("baseline_mae", model_mae)), model_mae)
+ uncertainty = max(float(selected.get("uncertainty", model_mae)), 1e-9)
+ volatility_percent = uncertainty * 100
+ expected_return_percent = (math.exp(expected_return) - 1) * 100
+ expected_gross_return_percent = (math.exp(expected_gross_return) - 1) * 100
+ q10_percent = (math.exp(float(selected.get("q10", expected_return))) - 1) * 100
+ q50_percent = (math.exp(float(selected.get("q50", expected_return))) - 1) * 100
+ q90_percent = (math.exp(float(selected.get("q90", expected_return))) - 1) * 100
+ skill = _clamp(_float_entry(entry, "skill", 0.0), -1.0, 1.0)
+ horizon = int(selected.get("horizon", _entry_horizon(entry, self.settings.time_series_forecast_horizon)))
+ min_edge = max(0.0, self.settings.time_series_min_edge_percent)
+ confidence_adjustment = _confidence_adjustment(
+ expected_return_percent=expected_return_percent,
+ probability_up=probability_up,
+ skill=skill,
+ min_edge=min_edge,
+ max_adjustment=self.settings.time_series_max_adjustment,
+ )
+ conservative_return_percent = min(expected_return_percent, q50_percent)
+ block_entry = bool(
+ (expected_return_percent <= -min_edge and probability_up <= 0.45)
+ or (q50_percent <= -min_edge and probability_up <= 0.48)
+ )
+ reason = _reason(
+ model=model,
+ expected_return_percent=expected_return_percent,
+ probability_up=probability_up,
+ skill=skill,
+ block_entry=block_entry,
+ )
+ return TimeSeriesForecast(
+ enabled=True,
+ usable=True,
+ model=model,
+ volatility_model="probabilistic multi-horizon after-cost quantile",
+ expected_return_percent=round(expected_return_percent, 4),
+ expected_price=round(expected_price, 8),
+ volatility_percent=round(volatility_percent, 4),
+ probability_up=round(probability_up, 4),
+ confidence_adjustment=round(confidence_adjustment, 4),
+ block_entry=block_entry,
+ validation_mae_percent=round(model_mae * 100, 4),
+ baseline_mae_percent=round(baseline_mae * 100, 4),
+ skill=round(skill, 4),
+ horizon=horizon,
+ reason=reason,
+ expected_gross_return_percent=round(expected_gross_return_percent, 4),
+ quantile_10_percent=round(q10_percent, 4),
+ quantile_50_percent=round(q50_percent, 4),
+ quantile_90_percent=round(q90_percent, 4),
+ conservative_return_percent=round(conservative_return_percent, 4),
+ target_transform=str(entry.get("target_transform", "net_return_over_volatility")),
+ horizon_forecasts=_public_horizon_forecasts(prediction),
+ candidates=[{"model": model, "mae_percent": round(model_mae * 100, 4)}],
+ )
+
direct_horizon = _is_direct_horizon(entry)
horizon = _entry_horizon(entry, self.settings.time_series_forecast_horizon)
expected_return = prediction if direct_horizon else prediction * horizon
@@ -145,6 +268,13 @@ class TimeSeriesForecaster:
skill=round(skill, 4),
horizon=horizon,
reason=reason,
+ expected_gross_return_percent=round(expected_return_percent, 4),
+ quantile_10_percent=round(expected_return_percent - volatility_percent, 4),
+ quantile_50_percent=round(expected_return_percent, 4),
+ quantile_90_percent=round(expected_return_percent + volatility_percent, 4),
+ conservative_return_percent=round(expected_return_percent, 4),
+ target_transform=str(entry.get("target_transform", "direct_log_return")),
+ horizon_forecasts={},
candidates=[{"model": model, "mae_percent": round(model_mae * 100, 4)}],
)
@@ -186,6 +316,13 @@ def _empty_forecast(enabled: bool, reason: str) -> TimeSeriesForecast:
skill=0.0,
horizon=0,
reason=reason,
+ expected_gross_return_percent=0.0,
+ quantile_10_percent=0.0,
+ quantile_50_percent=0.0,
+ quantile_90_percent=0.0,
+ conservative_return_percent=0.0,
+ target_transform="none",
+ horizon_forecasts={},
)
@@ -193,15 +330,58 @@ def _log_returns(closes: list[float]) -> list[float]:
return [math.log(closes[index] / closes[index - 1]) for index in range(1, len(closes))]
-def _feature_matrix(candles: list[Candle], feature_names: list[str] | tuple[str, ...] | None = None) -> list[list[float]]:
+def _feature_matrix(
+ candles: list[Candle],
+ feature_names: list[str] | tuple[str, ...] | None = None,
+ *,
+ symbol: str | None = None,
+ market_candles: dict[str, list[Candle]] | None = None,
+ trend_candles: list[Candle] | None = None,
+) -> list[list[float]]:
names = list(feature_names or DEFAULT_TORCH_FEATURES)
+ context = _feature_context(
+ candles,
+ symbol=symbol,
+ market_candles=market_candles,
+ trend_candles=trend_candles,
+ )
rows: list[list[float]] = []
for index, candle in enumerate(candles):
- rows.append([_feature_value(name, candles, index, candle) for name in names])
+ rows.append([_feature_value(name, candles, index, candle, context) for name in names])
return rows
-def _feature_value(name: str, candles: list[Candle], index: int, candle: Candle) -> float:
+def _feature_context(
+ candles: list[Candle],
+ *,
+ symbol: str | None,
+ market_candles: dict[str, list[Candle]] | None,
+ trend_candles: list[Candle] | None,
+) -> dict[str, Any]:
+ market_candles = market_candles or {}
+ normalized_market = {key.upper(): value for key, value in market_candles.items()}
+ context_indexes = {
+ key: {candle.timestamp: index for index, candle in enumerate(rows)}
+ for key, rows in normalized_market.items()
+ }
+ trend_rows = trend_candles or []
+ trend_timestamps = [candle.timestamp for candle in trend_rows]
+ trend_positions = [
+ bisect_right(trend_timestamps, candle.timestamp) - 1
+ if trend_timestamps
+ else -1
+ for candle in candles
+ ]
+ return {
+ "symbol": (symbol or "").upper(),
+ "market_candles": normalized_market,
+ "context_indexes": context_indexes,
+ "trend_candles": trend_rows,
+ "trend_positions": trend_positions,
+ }
+
+
+def _feature_value(name: str, candles: list[Candle], index: int, candle: Candle, context: dict[str, Any]) -> float:
close = max(float(candle.close), 1e-12)
previous = candles[index - 1] if index >= 1 else candle
if name == "return_1":
@@ -210,6 +390,10 @@ def _feature_value(name: str, candles: list[Candle], index: int, candle: Candle)
return _log_change(candle.close, candles[index - 3].close) if index >= 3 else 0.0
if name == "return_6":
return _log_change(candle.close, candles[index - 6].close) if index >= 6 else 0.0
+ if name == "return_12":
+ return _log_change(candle.close, candles[index - 12].close) if index >= 12 else 0.0
+ if name == "return_24":
+ return _log_change(candle.close, candles[index - 24].close) if index >= 24 else 0.0
if name == "range_percent":
return _safe_feature((candle.high - candle.low) / close)
if name == "body_percent":
@@ -222,16 +406,52 @@ def _feature_value(name: str, candles: list[Candle], index: int, candle: Candle)
return _log_change(max(candle.volume, 1e-12), max(previous.volume, 1e-12))
if name == "volume_ratio":
return _safe_feature((candle.volume / candle.volume_ma_20) - 1.0) if candle.volume_ma_20 else 0.0
+ if name == "volume_percentile_20":
+ return _rolling_percentile([row.volume for row in candles], index, 20)
if name == "atr_percent":
return _safe_feature(candle.atr_14 / close) if candle.atr_14 is not None else 0.0
+ if name == "atr_ratio_20":
+ return _ratio_to_recent_mean(
+ [row.atr_14 if row.atr_14 is not None else 0.0 for row in candles],
+ index,
+ 20,
+ )
+ if name == "realized_volatility_12":
+ return _realized_volatility(candles, index, 12)
+ if name == "realized_volatility_24":
+ return _realized_volatility(candles, index, 24)
if name == "rsi_centered":
return _safe_feature((candle.rsi_14 - 50.0) / 50.0) if candle.rsi_14 is not None else 0.0
+ if name == "rsi_slope_6":
+ return _indicator_slope(candles, index, "rsi_14", 6, divisor=50.0)
if name == "macd_hist_percent":
return _safe_feature(candle.macd_hist / close) if candle.macd_hist is not None else 0.0
+ if name == "macd_hist_slope_3":
+ return _indicator_price_slope(candles, index, "macd_hist", 3)
if name == "ema50_gap_percent":
return _safe_feature((candle.close - candle.ema_50) / close) if candle.ema_50 is not None else 0.0
if name == "ema200_gap_percent":
return _safe_feature((candle.close - candle.ema_200) / close) if candle.ema_200 is not None else 0.0
+ if name == "ema20_slope_6":
+ return _ema_slope(candles, index, "ema_20", 6)
+ if name == "ema50_slope_12":
+ return _ema_slope(candles, index, "ema_50", 12)
+ if name == "ema200_slope_24":
+ return _ema_slope(candles, index, "ema_200", 24)
+ if name == "ema50_ema200_gap_percent":
+ if candle.ema_50 is not None and candle.ema_200 is not None and candle.ema_200 > 0:
+ return _safe_feature((candle.ema_50 - candle.ema_200) / candle.ema_200)
+ return 0.0
+ if name == "range_position_50":
+ return _range_position(candles, index, 50)
+ if name == "trend_return_4h":
+ return _log_change(candle.close, candles[index - 4].close) if index >= 4 else 0.0
+ if name == "trend_return_24h":
+ return _log_change(candle.close, candles[index - 24].close) if index >= 24 else 0.0
+ if name.startswith("daily_"):
+ return _daily_feature_value(name, context, index)
+ if name.startswith("btc_") or name.startswith("eth_") or name.startswith("relative_"):
+ return _cross_asset_feature_value(name, candles, index, candle, context)
if name.startswith("pattern_"):
return _pattern_feature_value(name, candles, index)
return 0.0
@@ -266,6 +486,143 @@ def _pattern_feature_value(name: str, candles: list[Candle], index: int) -> floa
return 0.0
+def _rolling_percentile(values: list[float], index: int, window: int) -> float:
+ start = max(0, index - window + 1)
+ sample = [float(value) for value in values[start : index + 1] if math.isfinite(float(value))]
+ if not sample:
+ return 0.5
+ current = float(values[index])
+ below_or_equal = sum(1 for value in sample if value <= current)
+ return _clamp(below_or_equal / len(sample), 0.0, 1.0)
+
+
+def _ratio_to_recent_mean(values: list[float], index: int, window: int) -> float:
+ current = float(values[index]) if index < len(values) else 0.0
+ start = max(0, index - window + 1)
+ sample = [float(value) for value in values[start : index + 1] if math.isfinite(float(value)) and value > 0]
+ if current <= 0 or not sample:
+ return 0.0
+ mean = sum(sample) / len(sample)
+ return _safe_feature((current / mean) - 1.0) if mean > 0 else 0.0
+
+
+def _realized_volatility(candles: list[Candle], index: int, window: int) -> float:
+ if index < 1:
+ return 0.0
+ start = max(1, index - window + 1)
+ returns = [
+ _log_change(candles[position].close, candles[position - 1].close)
+ for position in range(start, index + 1)
+ ]
+ if not returns:
+ return 0.0
+ return _safe_feature(math.sqrt(sum(value * value for value in returns) / len(returns)))
+
+
+def _indicator_slope(candles: list[Candle], index: int, attr: str, steps: int, *, divisor: float) -> float:
+ if index < steps:
+ return 0.0
+ current = getattr(candles[index], attr)
+ previous = getattr(candles[index - steps], attr)
+ if current is None or previous is None or divisor <= 0:
+ return 0.0
+ return _safe_feature((float(current) - float(previous)) / divisor)
+
+
+def _indicator_price_slope(candles: list[Candle], index: int, attr: str, steps: int) -> float:
+ if index < steps:
+ return 0.0
+ current = getattr(candles[index], attr)
+ previous = getattr(candles[index - steps], attr)
+ close = max(float(candles[index].close), 1e-12)
+ if current is None or previous is None:
+ return 0.0
+ return _safe_feature((float(current) - float(previous)) / close)
+
+
+def _ema_slope(candles: list[Candle], index: int, attr: str, steps: int) -> float:
+ if index < steps:
+ return 0.0
+ current = getattr(candles[index], attr)
+ previous = getattr(candles[index - steps], attr)
+ if current is None or previous is None or previous <= 0:
+ return 0.0
+ return _safe_feature(math.log(float(current) / float(previous)))
+
+
+def _range_position(candles: list[Candle], index: int, window: int) -> float:
+ start = max(0, index - window + 1)
+ rows = candles[start : index + 1]
+ if not rows:
+ return 0.5
+ high = max(row.high for row in rows)
+ low = min(row.low for row in rows)
+ width = high - low
+ if width <= 0:
+ return 0.5
+ return _clamp((candles[index].close - low) / width, 0.0, 1.0)
+
+
+def _daily_feature_value(name: str, context: dict[str, Any], index: int) -> float:
+ trend_rows: list[Candle] = context.get("trend_candles") or []
+ trend_positions: list[int] = context.get("trend_positions") or []
+ if index >= len(trend_positions):
+ return 0.0
+ trend_index = trend_positions[index]
+ if trend_index < 0 or trend_index >= len(trend_rows):
+ return 0.0
+ trend = trend_rows[trend_index]
+ if name == "daily_close_ema200_gap_percent":
+ if trend.ema_200 is not None and trend.ema_200 > 0:
+ return _safe_feature((trend.close - trend.ema_200) / trend.ema_200)
+ return 0.0
+ if name == "daily_ema50_ema200_gap_percent":
+ if trend.ema_50 is not None and trend.ema_200 is not None and trend.ema_200 > 0:
+ return _safe_feature((trend.ema_50 - trend.ema_200) / trend.ema_200)
+ return 0.0
+ if name == "daily_ema50_slope":
+ previous_index = trend_index - 5
+ if previous_index >= 0 and trend.ema_50 is not None and trend_rows[previous_index].ema_50:
+ return _safe_feature(math.log(trend.ema_50 / trend_rows[previous_index].ema_50))
+ return 0.0
+
+
+def _cross_asset_feature_value(
+ name: str,
+ candles: list[Candle],
+ index: int,
+ candle: Candle,
+ context: dict[str, Any],
+) -> float:
+ if name == "btc_eth_return_spread_3":
+ return _safe_feature(
+ _context_return("BTCUSDT", 3, candle.timestamp, context)
+ - _context_return("ETHUSDT", 3, candle.timestamp, context)
+ )
+ if name.startswith("btc_return_"):
+ steps = int(name.rsplit("_", 1)[1])
+ return _context_return("BTCUSDT", steps, candle.timestamp, context)
+ if name.startswith("eth_return_"):
+ steps = int(name.rsplit("_", 1)[1])
+ return _context_return("ETHUSDT", steps, candle.timestamp, context)
+ if name == "relative_btc_return_3":
+ return _safe_feature((_log_change(candle.close, candles[index - 3].close) if index >= 3 else 0.0) - _context_return("BTCUSDT", 3, candle.timestamp, context))
+ if name == "relative_eth_return_3":
+ return _safe_feature((_log_change(candle.close, candles[index - 3].close) if index >= 3 else 0.0) - _context_return("ETHUSDT", 3, candle.timestamp, context))
+ return 0.0
+
+
+def _context_return(symbol: str, steps: int, timestamp: int, context: dict[str, Any]) -> float:
+ rows = (context.get("market_candles") or {}).get(symbol)
+ indexes = (context.get("context_indexes") or {}).get(symbol)
+ if not rows or not indexes:
+ return 0.0
+ index = indexes.get(timestamp)
+ if index is None or index < steps:
+ return 0.0
+ return _log_change(rows[index].close, rows[index - steps].close)
+
+
def _pattern_snapshot(candles: list[Candle], index: int) -> dict[str, float]:
if index < 29:
return {
@@ -486,7 +843,8 @@ def _torch_recurrent_predict(
*,
feature_rows: list[list[float]] | None = None,
closes: list[float] | None = None,
-) -> float | None:
+ candles: list[Candle] | None = None,
+) -> float | dict[str, Any] | None:
entry = _torch_recurrent_entry(symbol, artifact)
model_name = _torch_recurrent_model_name(symbol, artifact)
if not entry or not model_name:
@@ -523,11 +881,19 @@ def _torch_recurrent_predict(
)
if hidden is None:
return None
- head_weight = _float_vector(entry.get("head_weight"))
- head_bias = _float_entry(entry, "head_bias", 0.0)
- if len(head_weight) != hidden_size:
+ head_outputs = _torch_head_outputs(hidden, entry, hidden_size)
+ if not head_outputs:
return None
- normalized_prediction = sum(weight * value for weight, value in zip(head_weight, hidden)) + head_bias
+ if _entry_target_horizons(entry):
+ return _decode_multi_horizon_prediction(
+ outputs=head_outputs,
+ entry=entry,
+ returns=returns,
+ closes=closes or [],
+ candles=candles or [],
+ clip=clip,
+ )
+ normalized_prediction = head_outputs[0]
if not math.isfinite(normalized_prediction):
return None
prediction = _clamp(normalized_prediction, -clip, clip) * target_scale + target_mean
@@ -543,6 +909,111 @@ def _torch_recurrent_predict(
return _clamp(prediction, -cap, cap)
+def _torch_head_outputs(context: list[float], entry: dict[str, Any], hidden_size: int) -> list[float]:
+ context = _apply_context_norm(context, entry)
+ raw_weight = entry.get("head_weight")
+ if isinstance(raw_weight, list) and raw_weight and isinstance(raw_weight[0], list):
+ matrix = _float_matrix(raw_weight)
+ bias = _float_vector(entry.get("head_bias"))
+ if len(bias) != len(matrix):
+ bias = [0.0 for _ in matrix]
+ return [
+ _dot(row, context) + bias[index]
+ for index, row in enumerate(matrix)
+ if len(row) == hidden_size
+ ]
+ head_weight = _float_vector(raw_weight)
+ head_bias = _float_entry(entry, "head_bias", 0.0)
+ if len(head_weight) != hidden_size:
+ return []
+ return [sum(weight * value for weight, value in zip(head_weight, context)) + head_bias]
+
+
+def _apply_context_norm(context: list[float], entry: dict[str, Any]) -> list[float]:
+ weight = _float_vector(entry.get("context_norm_weight"))
+ bias = _float_vector(entry.get("context_norm_bias"))
+ if not weight or len(weight) != len(context):
+ return context
+ if len(bias) != len(context):
+ bias = [0.0 for _ in context]
+ mean = sum(context) / len(context)
+ variance = sum((value - mean) ** 2 for value in context) / len(context)
+ denominator = math.sqrt(variance + 1e-5)
+ return [
+ ((value - mean) / denominator) * weight[index] + bias[index]
+ for index, value in enumerate(context)
+ ]
+
+
+def _decode_multi_horizon_prediction(
+ *,
+ outputs: list[float],
+ entry: dict[str, Any],
+ returns: list[float],
+ closes: list[float],
+ candles: list[Candle],
+ clip: float,
+) -> dict[str, Any] | None:
+ horizons = _entry_target_horizons(entry)
+ if not horizons:
+ return None
+ layout = _entry_output_layout(entry)
+ group_size = len(layout)
+ if len(outputs) < len(horizons) * group_size:
+ return None
+ target_means = _target_vector(entry, "target_means", "target_mean", len(horizons), 0.0)
+ target_scales = _target_vector(entry, "target_scales", "target_scale", len(horizons), _return_scale(returns))
+ validation_mae = _target_vector(entry, "validation_mae_by_horizon", "validation_mae_percent", len(horizons), 0.0)
+ baseline_mae = _target_vector(entry, "baseline_mae_by_horizon", "baseline_mae_percent", len(horizons), 0.0)
+ round_trip_cost = max(0.0, _float_entry(entry, "round_trip_cost", 0.0))
+ result: dict[str, Any] = {"horizons": {}}
+ for horizon_index, horizon in enumerate(horizons):
+ base = horizon_index * group_size
+ values = {layout[offset]: outputs[base + offset] for offset in range(group_size)}
+ vol_scale = _current_volatility_scale(candles, closes, horizon)
+
+ def decode(name: str, fallback: float = 0.0) -> float:
+ normalized = _clamp(float(values.get(name, fallback)), -clip, clip)
+ transformed = normalized * max(target_scales[horizon_index], 1e-8) + target_means[horizon_index]
+ if str(entry.get("target_transform", "")) == "net_return_over_volatility":
+ return transformed * vol_scale
+ return transformed
+
+ expected = decode("mean")
+ q_values = sorted([decode("q10", expected), decode("q50", expected), decode("q90", expected)])
+ probability_up = _sigmoid(float(values.get("logit_up", 0.0)))
+ cap = _prediction_cap(closes, horizon, round_trip_cost)
+ expected = _clamp(expected, -cap, cap)
+ q10 = _clamp(q_values[0], -cap, cap)
+ q50 = _clamp(q_values[1], -cap, cap)
+ q90 = _clamp(q_values[2], -cap, cap)
+ mae = validation_mae[horizon_index]
+ if mae > 1.0:
+ mae = mae / 100
+ base_mae = baseline_mae[horizon_index]
+ if base_mae > 1.0:
+ base_mae = base_mae / 100
+ if mae <= 0:
+ mae = max(_horizon_return_scale(closes, horizon), 1e-9)
+ if base_mae <= 0:
+ base_mae = max(mae, _horizon_return_scale(closes, horizon))
+ uncertainty = max(abs(q90 - q10) * 0.5, mae, 1e-9)
+ result["horizons"][str(horizon)] = {
+ "horizon": horizon,
+ "expected_return": expected,
+ "expected_gross_return": expected + round_trip_cost,
+ "q10": q10,
+ "q50": q50,
+ "q90": q90,
+ "probability_up": probability_up,
+ "volatility_scale": vol_scale,
+ "validation_mae": mae,
+ "baseline_mae": base_mae,
+ "uncertainty": uncertainty,
+ }
+ return result
+
+
def _normalize_feature_rows(rows: list[list[float]], entry: dict[str, Any], clip: float) -> list[list[float]]:
means = _float_vector(entry.get("feature_means"))
scales = _float_vector(entry.get("feature_scales"))
@@ -575,6 +1046,7 @@ def _torch_recurrent_hidden(
return None
h_layers = [[0.0 for _ in range(hidden_size)] for _ in range(num_layers)]
c_layers = [[0.0 for _ in range(hidden_size)] for _ in range(num_layers)]
+ top_outputs: list[list[float]] = []
for row in sequence:
layer_input = list(row)
for layer in range(num_layers):
@@ -587,7 +1059,32 @@ def _torch_recurrent_hidden(
else:
return None
layer_input = h_layers[layer]
- return h_layers[-1]
+ top_outputs.append(list(layer_input))
+ if not top_outputs:
+ return None
+ if bool(entry.get("attention_pooling")):
+ return _attention_context(top_outputs, entry, hidden_size)
+ return top_outputs[-1]
+
+
+def _attention_context(outputs: list[list[float]], entry: dict[str, Any], hidden_size: int) -> list[float] | None:
+ weight = _float_vector(entry.get("attention_weight"))
+ if len(weight) != hidden_size:
+ return outputs[-1] if outputs else None
+ bias = _float_entry(entry, "attention_bias", 0.0)
+ scores = [_dot(weight, row) + bias for row in outputs]
+ if not scores:
+ return None
+ max_score = max(scores)
+ exps = [math.exp(_clamp(score - max_score, -50.0, 50.0)) for score in scores]
+ total = sum(exps)
+ if total <= 0:
+ return outputs[-1]
+ attention = [value / total for value in exps]
+ return [
+ sum(attention[row_index] * outputs[row_index][hidden_index] for row_index in range(len(outputs)))
+ for hidden_index in range(hidden_size)
+ ]
def _torch_lstm_step(
@@ -705,7 +1202,91 @@ def _is_direct_horizon(entry: dict[str, Any]) -> bool:
def _entry_horizon(entry: dict[str, Any], default: int) -> int:
- return int(_clamp(_float_entry(entry, "target_horizon", float(max(1, default))), 1.0, 96.0))
+ horizons = _entry_target_horizons(entry)
+ requested = int(_clamp(_float_entry(entry, "target_horizon", float(max(1, default))), 1.0, 96.0))
+ if horizons:
+ if requested in horizons:
+ return requested
+ return min(horizons, key=lambda value: abs(value - requested))
+ return requested
+
+
+def _entry_target_horizons(entry: dict[str, Any]) -> list[int]:
+ raw = entry.get("target_horizons")
+ if not isinstance(raw, list):
+ return []
+ horizons = []
+ for value in raw:
+ try:
+ horizon = int(value)
+ except (TypeError, ValueError):
+ continue
+ if 1 <= horizon <= 96 and horizon not in horizons:
+ horizons.append(horizon)
+ return horizons
+
+
+def _entry_output_layout(entry: dict[str, Any]) -> list[str]:
+ raw = entry.get("output_layout")
+ if isinstance(raw, list) and raw:
+ return [str(value) for value in raw]
+ return ["mean", "q10", "q50", "q90", "logit_up"]
+
+
+def _target_vector(
+ entry: dict[str, Any],
+ plural_key: str,
+ scalar_key: str,
+ size: int,
+ default: float,
+) -> list[float]:
+ raw = entry.get(plural_key)
+ if isinstance(raw, dict):
+ values = []
+ for horizon in _entry_target_horizons(entry):
+ values.append(float(raw.get(str(horizon), default)))
+ if len(values) == size:
+ return values
+ if isinstance(raw, list) and len(raw) == size:
+ return [float(value) for value in raw]
+ scalar = _float_entry(entry, scalar_key, default)
+ return [scalar for _ in range(size)]
+
+
+def _select_horizon_prediction(prediction: dict[str, Any], horizon: int) -> dict[str, Any] | None:
+ horizons = prediction.get("horizons")
+ if not isinstance(horizons, dict) or not horizons:
+ return None
+ key = str(horizon)
+ selected = horizons.get(key)
+ if isinstance(selected, dict):
+ return selected
+ numeric = []
+ for raw_key, value in horizons.items():
+ try:
+ numeric.append((abs(int(raw_key) - horizon), value))
+ except (TypeError, ValueError):
+ continue
+ numeric.sort(key=lambda item: item[0])
+ return numeric[0][1] if numeric and isinstance(numeric[0][1], dict) else None
+
+
+def _public_horizon_forecasts(prediction: dict[str, Any]) -> dict[str, Any]:
+ horizons = prediction.get("horizons")
+ if not isinstance(horizons, dict):
+ return {}
+ public: dict[str, Any] = {}
+ for key, row in horizons.items():
+ if not isinstance(row, dict):
+ continue
+ public[key] = {
+ "expected_return_percent": round((math.exp(float(row.get("expected_return", 0.0))) - 1) * 100, 4),
+ "probability_up": round(_clamp(float(row.get("probability_up", 0.5)), 0.0, 1.0), 4),
+ "quantile_10_percent": round((math.exp(float(row.get("q10", 0.0))) - 1) * 100, 4),
+ "quantile_50_percent": round((math.exp(float(row.get("q50", 0.0))) - 1) * 100, 4),
+ "quantile_90_percent": round((math.exp(float(row.get("q90", 0.0))) - 1) * 100, 4),
+ }
+ return public
def _float_entry(data: dict[str, Any], key: str, default: float) -> float:
@@ -762,6 +1343,24 @@ def _horizon_return_scale(closes: list[float], horizon: int) -> float:
return _return_scale(values) if values else 0.0005
+def _current_volatility_scale(candles: list[Candle], closes: list[float], horizon: int) -> float:
+ horizon = max(1, horizon)
+ latest = candles[-1] if candles else None
+ close = closes[-1] if closes else (latest.close if latest else 0.0)
+ atr_scale = 0.0
+ if latest and latest.atr_14 is not None and close > 0:
+ atr_scale = (latest.atr_14 / close) * math.sqrt(horizon)
+ realized = _horizon_return_scale(closes, horizon)
+ one_step = _return_scale(_log_returns(closes)) * math.sqrt(horizon) if len(closes) > 2 else 0.0
+ return max(atr_scale * 0.7, realized, one_step, 0.0005)
+
+
+def _prediction_cap(closes: list[float], horizon: int, round_trip_cost: float) -> float:
+ values = sorted(abs(value) for value in _horizon_log_returns(closes, horizon)[-96:])
+ base = values[int(len(values) * 0.9)] if values else 0.0
+ return max(base * 1.5 + round_trip_cost, 0.0005)
+
+
def _sigmoid(value: float) -> float:
if value >= 40:
return 1.0
diff --git a/tests/test_time_series.py b/tests/test_time_series.py
index 9ad6a32..876afce 100644
--- a/tests/test_time_series.py
+++ b/tests/test_time_series.py
@@ -124,6 +124,73 @@ def _write_multifeature_torch_gru_artifact(path, *, head_bias: float) -> None:
)
+def _write_probabilistic_torch_gru_artifact(path) -> None:
+ hidden_size = 2
+ input_size = 2
+ output_size = 10
+ path.write_text(
+ json.dumps(
+ {
+ "version": 4,
+ "type": "pytorch_recurrent_forecaster",
+ "target_horizon": 3,
+ "target_horizons": [1, 3],
+ "direct_horizon": True,
+ "target_transform": "net_return_over_volatility",
+ "round_trip_cost": 0.0026,
+ "output_layout": ["mean", "q10", "q50", "q90", "logit_up"],
+ "feature_count": input_size,
+ "feature_names": ["return_1", "range_percent"],
+ "symbols": {
+ "BTCUSDT": {
+ "model": "torch_gru",
+ "architecture": "gru",
+ "lookback": 8,
+ "target_horizon": 3,
+ "target_horizons": [1, 3],
+ "direct_horizon": True,
+ "target_transform": "net_return_over_volatility",
+ "round_trip_cost": 0.0026,
+ "output_layout": ["mean", "q10", "q50", "q90", "logit_up"],
+ "input_size": input_size,
+ "output_size": output_size,
+ "feature_names": ["return_1", "range_percent"],
+ "feature_means": [0.0, 0.0],
+ "feature_scales": [0.001, 0.001],
+ "target_means": [0.0, 0.0],
+ "target_scales": [1.0, 1.0],
+ "target_mean": 0.0,
+ "target_scale": 1.0,
+ "hidden_size": hidden_size,
+ "num_layers": 1,
+ "clip": 8.0,
+ "validation_mae_percent": 0.01,
+ "baseline_mae_percent": 0.08,
+ "validation_mae_by_horizon": {"1": 0.001, "3": 0.0015},
+ "baseline_mae_by_horizon": {"1": 0.002, "3": 0.003},
+ "skill": 0.2,
+ "attention_pooling": True,
+ "attention_weight": [0.0, 0.0],
+ "attention_bias": 0.0,
+ "context_norm": True,
+ "context_norm_weight": [1.0, 1.0],
+ "context_norm_bias": [0.0, 0.0],
+ "state_dict": {
+ "weight_ih_l0": [[0.0, 0.0] for _ in range(3 * hidden_size)],
+ "weight_hh_l0": [[0.0, 0.0] for _ in range(3 * hidden_size)],
+ "bias_ih_l0": [0.0 for _ in range(3 * hidden_size)],
+ "bias_hh_l0": [0.0 for _ in range(3 * hidden_size)],
+ },
+ "head_weight": [[0.0, 0.0] for _ in range(output_size)],
+ "head_bias": [0.2, 0.05, 0.15, 0.35, 1.0, 0.35, 0.10, 0.30, 0.55, 2.0],
+ },
+ },
+ }
+ ),
+ encoding="utf-8",
+ )
+
+
def test_time_series_forecaster_requires_torch_artifact(make_settings, tmp_path) -> None:
settings = make_settings(
tmp_path,
@@ -233,3 +300,25 @@ def test_time_series_forecaster_reads_multifeature_direct_horizon_artifact(make_
assert forecast.horizon == 3
assert 0.015 <= forecast.expected_return_percent <= 0.025
assert forecast.volatility_model == "direct horizon validation MAE"
+
+
+def test_time_series_forecaster_reads_probabilistic_multi_horizon_artifact(make_settings, tmp_path) -> None:
+ artifact_path = tmp_path / "lstm_forecaster.json"
+ _write_probabilistic_torch_gru_artifact(artifact_path)
+ settings = make_settings(
+ tmp_path,
+ time_series_min_candles=80,
+ time_series_forecast_horizon=3,
+ time_series_lstm_model_path=artifact_path,
+ )
+ returns = [0.0002 if index % 5 else -0.00007 for index in range(160)]
+
+ forecast = TimeSeriesForecaster(settings).forecast(_candles_from_returns(returns), symbol="BTCUSDT")
+
+ assert forecast.usable is True
+ assert forecast.model == "torch_gru"
+ assert forecast.horizon == 3
+ assert forecast.target_transform == "net_return_over_volatility"
+ assert forecast.probability_up > 0.85
+ assert forecast.quantile_10_percent <= forecast.quantile_50_percent <= forecast.quantile_90_percent
+ assert sorted(forecast.horizon_forecasts) == ["1", "3"]
diff --git a/tools/install_windows_torch_retrainer.ps1 b/tools/install_windows_torch_retrainer.ps1
index fa59dae..67ccb58 100644
--- a/tools/install_windows_torch_retrainer.ps1
+++ b/tools/install_windows_torch_retrainer.ps1
@@ -3,9 +3,11 @@ param(
[string]$TaskName = "TradeBot PyTorch Forecaster Retrainer",
[int]$EveryHours = 6,
[string]$Symbols = "BTCUSDT,ETHUSDT,SOLUSDT,LTCUSDT",
- [int]$Limit = 1000,
+ [int]$Limit = 3000,
[int]$Horizon = 0,
+ [string]$Horizons = "",
[string]$Features = "",
+ [string]$ContextSymbols = "",
[int]$FirstRunMinutes = 0
)
@@ -35,9 +37,15 @@ if ($Limit -gt 0) {
if ($Horizon -gt 0) {
$actionArgs += " -Horizon $Horizon"
}
+if ($Horizons) {
+ $actionArgs += " -Horizons `"$Horizons`""
+}
if ($Features) {
$actionArgs += " -Features `"$Features`""
}
+if ($ContextSymbols) {
+ $actionArgs += " -ContextSymbols `"$ContextSymbols`""
+}
$action = New-ScheduledTaskAction -Execute "powershell.exe" -Argument $actionArgs -WorkingDirectory $RepoRoot
$trigger = New-ScheduledTaskTrigger `
-Once `
diff --git a/tools/run_torch_retrain.ps1 b/tools/run_torch_retrain.ps1
index 64dbf4d..5b295ac 100644
--- a/tools/run_torch_retrain.ps1
+++ b/tools/run_torch_retrain.ps1
@@ -8,7 +8,9 @@ param(
[string]$Layers = "",
[string]$Dropouts = "",
[int]$Horizon = 0,
+ [string]$Horizons = "",
[string]$Features = "",
+ [string]$ContextSymbols = "",
[int]$Epochs = 0,
[int]$Patience = 0,
[string]$Interval = "",
@@ -51,17 +53,19 @@ function Resolve-Python {
if (-not $Symbols -and $env:TORCH_RETRAIN_SYMBOLS) { $Symbols = $env:TORCH_RETRAIN_SYMBOLS }
if ($Limit -le 0) {
- $Limit = if ($env:TORCH_RETRAIN_LIMIT) { [int]$env:TORCH_RETRAIN_LIMIT } else { 1000 }
+ $Limit = if ($env:TORCH_RETRAIN_LIMIT) { [int]$env:TORCH_RETRAIN_LIMIT } else { 3000 }
}
-if (-not $Lookbacks) { $Lookbacks = if ($env:TORCH_RETRAIN_LOOKBACKS) { $env:TORCH_RETRAIN_LOOKBACKS } else { "32,64" } }
+if (-not $Lookbacks) { $Lookbacks = if ($env:TORCH_RETRAIN_LOOKBACKS) { $env:TORCH_RETRAIN_LOOKBACKS } else { "64" } }
if (-not $Architectures) { $Architectures = if ($env:TORCH_RETRAIN_ARCHITECTURES) { $env:TORCH_RETRAIN_ARCHITECTURES } else { "lstm,gru" } }
-if (-not $HiddenSizes) { $HiddenSizes = if ($env:TORCH_RETRAIN_HIDDEN_SIZES) { $env:TORCH_RETRAIN_HIDDEN_SIZES } else { "32,64" } }
+if (-not $HiddenSizes) { $HiddenSizes = if ($env:TORCH_RETRAIN_HIDDEN_SIZES) { $env:TORCH_RETRAIN_HIDDEN_SIZES } else { "64,96" } }
if (-not $Layers) { $Layers = if ($env:TORCH_RETRAIN_LAYERS) { $env:TORCH_RETRAIN_LAYERS } else { "2" } }
if (-not $Dropouts) { $Dropouts = if ($env:TORCH_RETRAIN_DROPOUTS) { $env:TORCH_RETRAIN_DROPOUTS } else { "0.15" } }
if ($Horizon -le 0 -and $env:TORCH_RETRAIN_HORIZON) { $Horizon = [int]$env:TORCH_RETRAIN_HORIZON }
+if (-not $Horizons -and $env:TORCH_RETRAIN_HORIZONS) { $Horizons = $env:TORCH_RETRAIN_HORIZONS }
if (-not $Features -and $env:TORCH_RETRAIN_FEATURES) { $Features = $env:TORCH_RETRAIN_FEATURES }
-if ($Epochs -le 0) { $Epochs = if ($env:TORCH_RETRAIN_EPOCHS) { [int]$env:TORCH_RETRAIN_EPOCHS } else { 60 } }
-if ($Patience -le 0) { $Patience = if ($env:TORCH_RETRAIN_PATIENCE) { [int]$env:TORCH_RETRAIN_PATIENCE } else { 10 } }
+if (-not $ContextSymbols -and $env:TORCH_RETRAIN_CONTEXT_SYMBOLS) { $ContextSymbols = $env:TORCH_RETRAIN_CONTEXT_SYMBOLS }
+if ($Epochs -le 0) { $Epochs = if ($env:TORCH_RETRAIN_EPOCHS) { [int]$env:TORCH_RETRAIN_EPOCHS } else { 70 } }
+if ($Patience -le 0) { $Patience = if ($env:TORCH_RETRAIN_PATIENCE) { [int]$env:TORCH_RETRAIN_PATIENCE } else { 8 } }
if (-not $Interval -and $env:TORCH_RETRAIN_INTERVAL) { $Interval = $env:TORCH_RETRAIN_INTERVAL }
if (-not $EnvFile -and $env:TORCH_RETRAIN_ENV) { $EnvFile = $env:TORCH_RETRAIN_ENV }
if (-not $EnvFile -and (Test-Path (Join-Path $RepoRoot ".env"))) { $EnvFile = Join-Path $RepoRoot ".env" }
@@ -94,7 +98,9 @@ try {
if ($Interval) { $trainerArgs += @("--interval", $Interval) }
if ($EnvFile) { $trainerArgs += @("--env", $EnvFile) }
if ($Horizon -gt 0) { $trainerArgs += @("--horizon", $Horizon.ToString()) }
+ if ($Horizons) { $trainerArgs += @("--horizons", $Horizons) }
if ($Features) { $trainerArgs += @("--features", $Features) }
+ if ($ContextSymbols) { $trainerArgs += @("--context-symbols", $ContextSymbols) }
Push-Location $RepoRoot
$pushedLocation = $true
diff --git a/tools/train_torch_recurrent_forecaster.py b/tools/train_torch_recurrent_forecaster.py
index cd73338..346e0fa 100644
--- a/tools/train_torch_recurrent_forecaster.py
+++ b/tools/train_torch_recurrent_forecaster.py
@@ -4,6 +4,7 @@ import argparse
import json
import math
import sys
+import time
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
@@ -30,22 +31,40 @@ from crypto_spot_bot.models import Candle
from crypto_spot_bot.time_series import DEFAULT_TORCH_FEATURES, _feature_matrix, _log_returns
+OUTPUT_LAYOUT = ("mean", "q10", "q50", "q90", "logit_up")
+QUANTILES = {"q10": 0.10, "q50": 0.50, "q90": 0.90}
+
+
@dataclass(slots=True)
class PreparedData:
train_x: torch.Tensor
train_y: torch.Tensor
+ train_up: torch.Tensor
validation_x: torch.Tensor
validation_y: torch.Tensor
- validation_targets: list[float]
+ validation_up: torch.Tensor
+ validation_targets: list[list[float]]
+ validation_volatility_scales: list[list[float]]
feature_names: list[str]
feature_means: list[float]
feature_scales: list[float]
- target_mean: float
- target_scale: float
+ target_means: list[float]
+ target_scales: list[float]
+ target_horizons: list[int]
+ decision_horizon: int
+ decision_horizon_index: int
train_samples: int
validation_samples: int
+@dataclass(slots=True)
+class TrainingSample:
+ window: list[list[float]]
+ normalized_targets: list[float]
+ raw_targets: list[float]
+ volatility_scales: list[float]
+
+
class RecurrentReturnModel(nn.Module):
def __init__(
self,
@@ -55,6 +74,9 @@ class RecurrentReturnModel(nn.Module):
hidden_size: int,
num_layers: int,
dropout: float,
+ output_size: int,
+ attention_pooling: bool,
+ context_norm: bool,
) -> None:
super().__init__()
recurrent_cls = nn.LSTM if architecture == "lstm" else nn.GRU
@@ -65,11 +87,19 @@ class RecurrentReturnModel(nn.Module):
dropout=dropout if num_layers > 1 else 0.0,
batch_first=True,
)
- self.head = nn.Linear(hidden_size, 1)
+ self.attention = nn.Linear(hidden_size, 1) if attention_pooling else None
+ self.context_norm = nn.LayerNorm(hidden_size) if context_norm else nn.Identity()
+ self.head = nn.Linear(hidden_size, output_size)
def forward(self, values: torch.Tensor) -> torch.Tensor:
output, _state = self.rnn(values)
- return self.head(output[:, -1, :]).squeeze(-1)
+ if self.attention is not None:
+ scores = self.attention(output).squeeze(-1)
+ weights = torch.softmax(scores, dim=1).unsqueeze(-1)
+ context = (output * weights).sum(dim=1)
+ else:
+ context = output[:, -1, :]
+ return self.head(self.context_norm(context))
def main() -> None:
@@ -84,19 +114,27 @@ def main() -> None:
interval = args.interval or settings.base_interval
output = Path(args.output) if args.output else settings.time_series_lstm_model_path
device = _device(args.device)
- horizon = args.horizon if args.horizon > 0 else max(1, settings.time_series_forecast_horizon)
+ decision_horizon = args.horizon if args.horizon > 0 else max(1, settings.time_series_forecast_horizon)
+ target_horizons = _horizons(args.horizons, decision_horizon)
feature_names = _feature_names_arg(args.features)
+ round_trip_cost = max(0.0, 2.0 * (float(settings.taker_fee_rate) + float(settings.slippage_rate)))
artifact: dict[str, Any] = {
- "version": 3,
+ "version": 4,
"type": "pytorch_recurrent_forecaster",
"created_at": datetime.now(timezone.utc).isoformat(),
"trainer": Path(__file__).name,
"interval": interval,
"limit": args.limit,
"validation_window": args.validation_window,
- "target_horizon": horizon,
+ "target_horizon": decision_horizon,
+ "target_horizons": target_horizons,
"direct_horizon": True,
+ "target_transform": "net_return_over_volatility",
+ "target_return": "round_trip_after_cost_log_return",
+ "round_trip_cost": round(round_trip_cost, 10),
+ "output_layout": list(OUTPUT_LAYOUT),
+ "quantiles": list(QUANTILES.values()),
"feature_names": feature_names,
"feature_count": len(feature_names),
"device": str(device),
@@ -110,8 +148,11 @@ def main() -> None:
interval=interval,
limit=args.limit,
validation_window=args.validation_window,
- target_horizon=horizon,
+ target_horizons=target_horizons,
+ decision_horizon=decision_horizon,
feature_names=feature_names,
+ round_trip_cost=round_trip_cost,
+ context_symbols=_strings(args.context_symbols),
architectures=_strings(args.architectures),
lookbacks=_ints(args.lookbacks),
hidden_sizes=_ints(args.hidden_sizes),
@@ -123,6 +164,8 @@ def main() -> None:
learning_rate=args.learning_rate,
weight_decay=args.weight_decay,
clip=args.clip,
+ attention_pooling=args.attention_pooling,
+ context_norm=args.context_norm,
device=device,
seed=args.seed,
)
@@ -133,10 +176,11 @@ def main() -> None:
print(
f"{symbol}: model={result['model']} lookback={result['lookback']} "
f"features={result['input_size']} hidden={result['hidden_size']} "
- f"layers={result['num_layers']} horizon={result['target_horizon']} "
+ f"layers={result['num_layers']} horizons={','.join(map(str, result['target_horizons']))} "
f"mae={result['validation_mae_percent']:.5f}% "
f"baseline={result['baseline_mae_percent']:.5f}% "
- f"skill={result['skill']:.4f} dir={result['directional_accuracy']:.3f}"
+ f"skill={result['skill']:.4f} dir={result['directional_accuracy']:.3f} "
+ f"p_brier={result['probability_brier']:.4f}"
)
output.parent.mkdir(parents=True, exist_ok=True)
@@ -154,7 +198,9 @@ def _parse_args() -> argparse.Namespace:
parser.add_argument("--limit", type=int, default=1000, help="Kline limit per symbol.")
parser.add_argument("--validation-window", type=int, default=120, help="Held-out tail targets used for validation.")
parser.add_argument("--horizon", type=int, default=0, help="Direct forecast horizon in candles. Defaults to TIME_SERIES_FORECAST_HORIZON.")
+ parser.add_argument("--horizons", default="1,3,6,12", help="Comma-separated direct forecast horizons.")
parser.add_argument("--features", default=",".join(DEFAULT_TORCH_FEATURES), help="Comma-separated feature names.")
+ parser.add_argument("--context-symbols", default="BTCUSDT,ETHUSDT", help="Cross-asset context symbols.")
parser.add_argument("--architectures", default="lstm,gru", help="Comma-separated recurrent types: lstm,gru.")
parser.add_argument("--lookbacks", default="32,64", help="Comma-separated sequence lengths.")
parser.add_argument("--hidden-sizes", default="32,64", help="Comma-separated hidden sizes.")
@@ -166,6 +212,8 @@ def _parse_args() -> argparse.Namespace:
parser.add_argument("--learning-rate", type=float, default=0.001, help="AdamW learning rate.")
parser.add_argument("--weight-decay", type=float, default=0.0001, help="AdamW weight decay.")
parser.add_argument("--clip", type=float, default=8.0, help="Clamp normalized features, targets and predictions.")
+ parser.add_argument("--attention-pooling", action=argparse.BooleanOptionalAction, default=True, help="Use exportable attention pooling over recurrent states.")
+ parser.add_argument("--context-norm", action=argparse.BooleanOptionalAction, default=True, help="Use exportable LayerNorm before the forecast head.")
parser.add_argument("--seed", type=int, default=7, help="Random seed.")
parser.add_argument("--threads", type=int, default=0, help="Torch CPU threads; 0 keeps torch default.")
parser.add_argument("--device", default="auto", help="auto, cpu, cuda, or mps.")
@@ -188,8 +236,11 @@ def _train_symbol(
interval: str,
limit: int,
validation_window: int,
- target_horizon: int,
+ target_horizons: list[int],
+ decision_horizon: int,
feature_names: list[str],
+ round_trip_cost: float,
+ context_symbols: list[str],
architectures: list[str],
lookbacks: list[int],
hidden_sizes: list[int],
@@ -201,15 +252,31 @@ def _train_symbol(
learning_rate: float,
weight_decay: float,
clip: float,
+ attention_pooling: bool,
+ context_norm: bool,
device: torch.device,
seed: int,
) -> dict[str, Any] | None:
- candles = client.klines(symbol, interval, limit)
+ candles = _historical_klines(client, symbol, interval, limit)
add_indicators(candles)
closes = [float(candle.close) for candle in candles if candle.close > 0]
returns = _log_returns(closes)
- if len(candles) < max(140, validation_window + max(lookbacks) + target_horizon + 16):
+ max_horizon = max(target_horizons)
+ if len(candles) < max(180, validation_window + max(lookbacks) + max_horizon + 16):
return None
+ market_candles: dict[str, list[Candle]] = {symbol.upper(): candles}
+ for context_symbol in context_symbols:
+ context_symbol = context_symbol.upper()
+ if context_symbol in market_candles:
+ continue
+ try:
+ rows = _historical_klines(client, context_symbol, interval, limit)
+ add_indicators(rows)
+ market_candles[context_symbol] = rows
+ except Exception as exc:
+ print(f"{symbol}: context {context_symbol} skipped: {exc}")
+ trend_candles = _historical_klines(client, symbol, "D", min(max(260, limit // 24 + 260), 1000))
+ add_indicators(trend_candles)
best: dict[str, Any] | None = None
for lookback in lookbacks:
@@ -217,14 +284,21 @@ def _train_symbol(
candles=candles,
feature_names=feature_names,
lookback=lookback,
- target_horizon=target_horizon,
+ target_horizons=target_horizons,
+ decision_horizon=decision_horizon,
+ round_trip_cost=round_trip_cost,
+ market_candles=market_candles,
+ trend_candles=trend_candles,
validation_window=validation_window,
clip=clip,
device=device,
)
if prepared is None:
continue
- baseline_mae = sum(abs(value) for value in prepared.validation_targets) / len(prepared.validation_targets)
+ baseline_mae = (
+ sum(abs(value[prepared.decision_horizon_index]) for value in prepared.validation_targets)
+ / len(prepared.validation_targets)
+ )
for architecture in architectures:
if architecture not in {"lstm", "gru"}:
continue
@@ -237,6 +311,7 @@ def _train_symbol(
prepared=prepared,
architecture=architecture,
input_size=len(feature_names),
+ output_size=len(target_horizons) * len(OUTPUT_LAYOUT),
hidden_size=hidden_size,
num_layers=num_layers,
dropout=dropout,
@@ -246,6 +321,8 @@ def _train_symbol(
learning_rate=learning_rate,
weight_decay=weight_decay,
clip=clip,
+ attention_pooling=attention_pooling,
+ context_norm=context_norm,
device=device,
seed=seed,
)
@@ -256,19 +333,30 @@ def _train_symbol(
"model": f"torch_{architecture}",
"architecture": architecture,
"lookback": lookback,
- "target_horizon": target_horizon,
+ "target_horizon": prepared.decision_horizon,
+ "target_horizons": prepared.target_horizons,
"direct_horizon": True,
+ "target_transform": "net_return_over_volatility",
+ "target_return": "round_trip_after_cost_log_return",
+ "round_trip_cost": round(round_trip_cost, 10),
+ "output_layout": list(OUTPUT_LAYOUT),
+ "quantiles": list(QUANTILES.values()),
"input_size": len(feature_names),
+ "output_size": len(target_horizons) * len(OUTPUT_LAYOUT),
"feature_names": feature_names,
"feature_means": prepared.feature_means,
"feature_scales": prepared.feature_scales,
- "target_mean": prepared.target_mean,
- "target_scale": prepared.target_scale,
- "mean": prepared.target_mean,
- "scale": prepared.target_scale,
+ "target_means": prepared.target_means,
+ "target_scales": prepared.target_scales,
+ "target_mean": prepared.target_means[prepared.decision_horizon_index],
+ "target_scale": prepared.target_scales[prepared.decision_horizon_index],
+ "mean": prepared.target_means[prepared.decision_horizon_index],
+ "scale": prepared.target_scales[prepared.decision_horizon_index],
"hidden_size": hidden_size,
"num_layers": num_layers,
"dropout": dropout if num_layers > 1 else 0.0,
+ "attention_pooling": attention_pooling,
+ "context_norm": context_norm,
"clip": clip,
"validation_mae_percent": validation_mae * 100,
"baseline_mae_percent": baseline_mae * 100,
@@ -292,23 +380,47 @@ def _prepare_data(
candles: list[Candle],
feature_names: list[str],
lookback: int,
- target_horizon: int,
+ target_horizons: list[int],
+ decision_horizon: int,
+ round_trip_cost: float,
+ market_candles: dict[str, list[Candle]],
+ trend_candles: list[Candle],
validation_window: int,
clip: float,
device: torch.device,
) -> PreparedData | None:
closes = [float(candle.close) for candle in candles]
- feature_rows = _feature_matrix(candles, feature_names)
- samples: list[tuple[list[list[float]], float]] = []
- for end_index in range(lookback - 1, len(candles) - target_horizon):
+ feature_rows = _feature_matrix(
+ candles,
+ feature_names,
+ market_candles=market_candles,
+ trend_candles=trend_candles,
+ )
+ max_horizon = max(target_horizons)
+ samples: list[TrainingSample] = []
+ for end_index in range(lookback - 1, len(candles) - max_horizon):
current = closes[end_index]
- future = closes[end_index + target_horizon]
- if current <= 0 or future <= 0:
+ if current <= 0:
continue
window = feature_rows[end_index - lookback + 1 : end_index + 1]
if len(window) != lookback:
continue
- samples.append((window, math.log(future / current)))
+ raw_targets: list[float] = []
+ volatility_scales: list[float] = []
+ normalized_targets: list[float] = []
+ valid = True
+ for horizon in target_horizons:
+ future = closes[end_index + horizon]
+ if future <= 0:
+ valid = False
+ break
+ net_return = math.log(future / current) - round_trip_cost
+ volatility_scale = _target_volatility_scale(candles, closes, end_index, horizon)
+ raw_targets.append(net_return)
+ volatility_scales.append(volatility_scale)
+ normalized_targets.append(net_return / max(volatility_scale, 1e-8))
+ if valid:
+ samples.append(TrainingSample(window, normalized_targets, raw_targets, volatility_scales))
if len(samples) < 48:
return None
@@ -319,45 +431,55 @@ def _prepare_data(
return None
feature_means, feature_scales = _feature_stats(train_samples, len(feature_names))
- train_targets = [target for _, target in train_samples]
- target_mean = sum(train_targets) / len(train_targets)
- target_scale = _return_scale(train_targets)
+ target_means, target_scales = _target_stats(train_samples, len(target_horizons))
+ decision_horizon = decision_horizon if decision_horizon in target_horizons else min(
+ target_horizons,
+ key=lambda value: abs(value - decision_horizon),
+ )
+ decision_horizon_index = target_horizons.index(decision_horizon)
- train_x, train_y = _normalize_samples(
+ train_x, train_y, train_up = _normalize_samples(
train_samples,
feature_means=feature_means,
feature_scales=feature_scales,
- target_mean=target_mean,
- target_scale=target_scale,
+ target_means=target_means,
+ target_scales=target_scales,
clip=clip,
)
- validation_x, validation_y = _normalize_samples(
+ validation_x, validation_y, validation_up = _normalize_samples(
validation_samples,
feature_means=feature_means,
feature_scales=feature_scales,
- target_mean=target_mean,
- target_scale=target_scale,
+ target_means=target_means,
+ target_scales=target_scales,
clip=clip,
)
return PreparedData(
train_x=torch.tensor(train_x, dtype=torch.float32, device=device),
train_y=torch.tensor(train_y, dtype=torch.float32, device=device),
+ train_up=torch.tensor(train_up, dtype=torch.float32, device=device),
validation_x=torch.tensor(validation_x, dtype=torch.float32, device=device),
validation_y=torch.tensor(validation_y, dtype=torch.float32, device=device),
- validation_targets=[target for _, target in validation_samples],
+ validation_up=torch.tensor(validation_up, dtype=torch.float32, device=device),
+ validation_targets=[sample.raw_targets for sample in validation_samples],
+ validation_volatility_scales=[sample.volatility_scales for sample in validation_samples],
feature_names=feature_names,
feature_means=feature_means,
feature_scales=feature_scales,
- target_mean=target_mean,
- target_scale=target_scale,
+ target_means=target_means,
+ target_scales=target_scales,
+ target_horizons=target_horizons,
+ decision_horizon=decision_horizon,
+ decision_horizon_index=decision_horizon_index,
train_samples=len(train_x),
validation_samples=len(validation_x),
)
-def _feature_stats(samples: list[tuple[list[list[float]], float]], input_size: int) -> tuple[list[float], list[float]]:
+def _feature_stats(samples: list[TrainingSample], input_size: int) -> tuple[list[float], list[float]]:
columns = [[] for _ in range(input_size)]
- for window, _target in samples:
+ for sample in samples:
+ window = sample.window
for row in window:
for index in range(input_size):
columns[index].append(float(row[index] if index < len(row) else 0.0))
@@ -377,19 +499,32 @@ def _feature_stats(samples: list[tuple[list[list[float]], float]], input_size: i
return means, scales
+def _target_stats(samples: list[TrainingSample], output_size: int) -> tuple[list[float], list[float]]:
+ means: list[float] = []
+ scales: list[float] = []
+ for index in range(output_size):
+ values = [sample.normalized_targets[index] for sample in samples]
+ mean = sum(values) / len(values) if values else 0.0
+ means.append(mean)
+ scales.append(_return_scale([value - mean for value in values]))
+ return means, scales
+
+
def _normalize_samples(
- samples: list[tuple[list[list[float]], float]],
+ samples: list[TrainingSample],
*,
feature_means: list[float],
feature_scales: list[float],
- target_mean: float,
- target_scale: float,
+ target_means: list[float],
+ target_scales: list[float],
clip: float,
-) -> tuple[list[list[list[float]]], list[float]]:
+) -> tuple[list[list[list[float]]], list[list[float]], list[list[float]]]:
input_size = len(feature_means)
x_values: list[list[list[float]]] = []
- y_values: list[float] = []
- for window, target in samples:
+ y_values: list[list[float]] = []
+ up_values: list[list[float]] = []
+ for sample in samples:
+ window = sample.window
x_values.append(
[
[
@@ -404,8 +539,18 @@ def _normalize_samples(
for row in window
]
)
- y_values.append(_clamp((target - target_mean) / max(target_scale, 1e-8), -clip, clip))
- return x_values, y_values
+ y_values.append(
+ [
+ _clamp(
+ (target - target_means[index]) / max(target_scales[index], 1e-8),
+ -clip,
+ clip,
+ )
+ for index, target in enumerate(sample.normalized_targets)
+ ]
+ )
+ up_values.append([1.0 if target > 0 else 0.0 for target in sample.raw_targets])
+ return x_values, y_values, up_values
def _fit_candidate(
@@ -413,6 +558,7 @@ def _fit_candidate(
prepared: PreparedData,
architecture: str,
input_size: int,
+ output_size: int,
hidden_size: int,
num_layers: int,
dropout: float,
@@ -422,6 +568,8 @@ def _fit_candidate(
learning_rate: float,
weight_decay: float,
clip: float,
+ attention_pooling: bool,
+ context_norm: bool,
device: torch.device,
seed: int,
) -> dict[str, Any]:
@@ -432,12 +580,14 @@ def _fit_candidate(
hidden_size=hidden_size,
num_layers=num_layers,
dropout=dropout,
+ output_size=output_size,
+ attention_pooling=attention_pooling,
+ context_norm=context_norm,
).to(device)
optimizer = torch.optim.AdamW(model.parameters(), lr=learning_rate, weight_decay=weight_decay)
- criterion = nn.SmoothL1Loss(beta=0.5)
generator = torch.Generator(device="cpu").manual_seed(seed)
loader = DataLoader(
- TensorDataset(prepared.train_x, prepared.train_y),
+ TensorDataset(prepared.train_x, prepared.train_y, prepared.train_up),
batch_size=max(1, batch_size),
shuffle=True,
generator=generator,
@@ -449,9 +599,9 @@ def _fit_candidate(
stale_epochs = 0
for epoch in range(1, max(1, epochs) + 1):
model.train()
- for batch_x, batch_y in loader:
+ for batch_x, batch_y, batch_up in loader:
optimizer.zero_grad(set_to_none=True)
- loss = criterion(model(batch_x), batch_y)
+ loss = _forecast_loss(model(batch_x), batch_y, batch_up, len(prepared.target_horizons))
loss.backward()
nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
optimizer.step()
@@ -474,40 +624,79 @@ def _fit_candidate(
"best_epoch": best_epoch,
"epochs_trained": best_epoch + stale_epochs,
"state_dict": _export_recurrent_state(model),
- "head_weight": _round_list(model.head.weight.detach().cpu().squeeze(0).tolist()),
- "head_bias": round(float(model.head.bias.detach().cpu().item()), 10),
+ "head_weight": _round_nested(model.head.weight.detach().cpu().tolist()),
+ "head_bias": _round_list(model.head.bias.detach().cpu().tolist()),
+ **_export_context_state(model),
}
def _validation_metrics(model: nn.Module, prepared: PreparedData, clip: float) -> dict[str, float]:
model.eval()
with torch.no_grad():
- normalized_predictions = model(prepared.validation_x).detach().cpu().tolist()
- predictions = [
- _clamp(float(prediction), -clip, clip) * prepared.target_scale + prepared.target_mean
- for prediction in normalized_predictions
- ]
- errors = [abs(prediction - actual) for prediction, actual in zip(predictions, prepared.validation_targets)]
+ raw_outputs = model(prepared.validation_x).detach().cpu()
+ outputs = raw_outputs.view(len(prepared.validation_targets), len(prepared.target_horizons), len(OUTPUT_LAYOUT))
+ mean_predictions = outputs[:, :, 0].tolist()
+ logit_predictions = outputs[:, :, 4].tolist()
+ predictions: list[list[float]] = []
+ probabilities: list[list[float]] = []
+ for row_index, row in enumerate(mean_predictions):
+ predicted_row: list[float] = []
+ probability_row: list[float] = []
+ for horizon_index, normalized_prediction in enumerate(row):
+ transformed = (
+ _clamp(float(normalized_prediction), -clip, clip)
+ * prepared.target_scales[horizon_index]
+ + prepared.target_means[horizon_index]
+ )
+ predicted_row.append(transformed * prepared.validation_volatility_scales[row_index][horizon_index])
+ probability_row.append(_sigmoid(float(logit_predictions[row_index][horizon_index])))
+ predictions.append(predicted_row)
+ probabilities.append(probability_row)
+ decision = prepared.decision_horizon_index
+ decision_predictions = [row[decision] for row in predictions]
+ decision_targets = [row[decision] for row in prepared.validation_targets]
+ errors = [abs(prediction - actual) for prediction, actual in zip(decision_predictions, decision_targets)]
correct = [
1.0
- for prediction, actual in zip(predictions, prepared.validation_targets)
+ for prediction, actual in zip(decision_predictions, decision_targets)
if (prediction > 0 and actual > 0) or (prediction < 0 and actual < 0)
]
non_zero = [
1.0
- for prediction, actual in zip(predictions, prepared.validation_targets)
+ for prediction, actual in zip(decision_predictions, decision_targets)
if prediction != 0 and actual != 0
]
buy_predictions = [
actual
- for prediction, actual in zip(predictions, prepared.validation_targets)
+ for prediction, actual in zip(decision_predictions, decision_targets)
if prediction > 0
]
buy_wins = [actual for actual in buy_predictions if actual > 0]
+ by_horizon = {}
+ baseline_by_horizon = {}
+ for horizon_index, horizon in enumerate(prepared.target_horizons):
+ horizon_errors = [
+ abs(row[horizon_index] - actual[horizon_index])
+ for row, actual in zip(predictions, prepared.validation_targets)
+ ]
+ horizon_baseline = [abs(actual[horizon_index]) for actual in prepared.validation_targets]
+ by_horizon[str(horizon)] = sum(horizon_errors) / len(horizon_errors) if horizon_errors else math.inf
+ baseline_by_horizon[str(horizon)] = (
+ sum(horizon_baseline) / len(horizon_baseline)
+ if horizon_baseline
+ else math.inf
+ )
+ probability_errors = [
+ (probabilities[row_index][decision] - (1.0 if target > 0 else 0.0)) ** 2
+ for row_index, target in enumerate(decision_targets)
+ ]
return {
"validation_mae": sum(errors) / len(errors) if errors else math.inf,
+ "validation_mae_by_horizon": by_horizon,
+ "baseline_mae_by_horizon": baseline_by_horizon,
"directional_accuracy": len(correct) / len(non_zero) if non_zero else 0.0,
"buy_precision": len(buy_wins) / len(buy_predictions) if buy_predictions else 0.0,
+ "probability_brier": sum(probability_errors) / len(probability_errors) if probability_errors else 1.0,
}
@@ -516,9 +705,26 @@ def _candidate_score(row: dict[str, Any]) -> float:
skill = float(row.get("skill", 0.0))
directional = float(row.get("directional_accuracy", 0.0))
buy_precision = float(row.get("buy_precision", 0.0))
+ probability_brier = float(row.get("probability_brier", 1.0))
return mae * (1.0 - max(0.0, skill) * 0.05) * (1.0 - max(0.0, directional - 0.5) * 0.03) * (
1.0 - max(0.0, buy_precision - 0.5) * 0.02
- )
+ ) * (1.0 + max(0.0, probability_brier - 0.25) * 0.02)
+
+
+def _forecast_loss(outputs: torch.Tensor, targets: torch.Tensor, up_targets: torch.Tensor, horizon_count: int) -> torch.Tensor:
+ values = outputs.view(outputs.shape[0], horizon_count, len(OUTPUT_LAYOUT))
+ mean_loss = nn.functional.smooth_l1_loss(values[:, :, 0], targets, beta=0.5)
+ quantile_losses = []
+ for offset, name in enumerate(("q10", "q50", "q90"), start=1):
+ quantile = QUANTILES[name]
+ errors = targets - values[:, :, offset]
+ quantile_losses.append(torch.maximum((quantile - 1.0) * errors, quantile * errors).mean())
+ logits = values[:, :, 4]
+ bce = nn.functional.binary_cross_entropy_with_logits(logits, up_targets, reduction="none")
+ probabilities = torch.sigmoid(logits)
+ pt = probabilities * up_targets + (1.0 - probabilities) * (1.0 - up_targets)
+ focal = ((1.0 - pt) ** 2.0 * bce).mean()
+ return mean_loss + 0.35 * sum(quantile_losses) / len(quantile_losses) + 0.15 * focal
def _export_recurrent_state(model: RecurrentReturnModel) -> dict[str, Any]:
@@ -528,6 +734,23 @@ def _export_recurrent_state(model: RecurrentReturnModel) -> dict[str, Any]:
}
+def _export_context_state(model: RecurrentReturnModel) -> dict[str, Any]:
+ exported: dict[str, Any] = {}
+ if model.attention is not None:
+ exported["attention_pooling"] = True
+ exported["attention_weight"] = _round_list(model.attention.weight.detach().cpu().squeeze(0).tolist())
+ exported["attention_bias"] = round(float(model.attention.bias.detach().cpu().item()), 10)
+ else:
+ exported["attention_pooling"] = False
+ if isinstance(model.context_norm, nn.LayerNorm):
+ exported["context_norm"] = True
+ exported["context_norm_weight"] = _round_list(model.context_norm.weight.detach().cpu().tolist())
+ exported["context_norm_bias"] = _round_list(model.context_norm.bias.detach().cpu().tolist())
+ else:
+ exported["context_norm"] = False
+ return exported
+
+
def _device(raw: str) -> torch.device:
value = raw.strip().lower()
if value == "auto":
@@ -554,10 +777,90 @@ def _return_scale(returns: list[float]) -> float:
return max(max(median, mean * 0.5), 1e-5)
+def _target_volatility_scale(candles: list[Candle], closes: list[float], end_index: int, horizon: int) -> float:
+ horizon = max(1, horizon)
+ close = max(closes[end_index], 1e-12)
+ candle = candles[end_index]
+ atr_scale = (candle.atr_14 / close) * math.sqrt(horizon) if candle.atr_14 is not None else 0.0
+ start = max(1, end_index - 96)
+ returns = [
+ math.log(closes[index] / closes[index - 1])
+ for index in range(start, end_index + 1)
+ if closes[index] > 0 and closes[index - 1] > 0
+ ]
+ realized = math.sqrt(sum(value * value for value in returns) / len(returns)) * math.sqrt(horizon) if returns else 0.0
+ return max(atr_scale * 0.7, realized, 0.0005)
+
+
+def _historical_klines(client: BybitClient, symbol: str, interval: str, limit: int) -> list[Candle]:
+ limit = max(1, limit)
+ rows_by_timestamp: dict[int, Candle] = {}
+ end: int | None = None
+ while len(rows_by_timestamp) < limit:
+ page_limit = min(1000, limit - len(rows_by_timestamp))
+ params: dict[str, Any] = {
+ "category": "spot",
+ "symbol": symbol,
+ "interval": interval,
+ "limit": page_limit,
+ }
+ if end is not None:
+ params["end"] = end
+ result = client.public_get("/v5/market/kline", params)
+ page = _parse_kline_rows(result.get("list", []))
+ if not page:
+ break
+ for candle in page:
+ rows_by_timestamp[candle.timestamp] = candle
+ oldest = min(candle.timestamp for candle in page)
+ if end is not None and oldest >= end:
+ break
+ end = oldest - 1
+ if len(page) < page_limit:
+ break
+ time.sleep(0.05)
+ return sorted(rows_by_timestamp.values(), key=lambda item: item.timestamp)[-limit:]
+
+
+def _parse_kline_rows(rows: Any) -> list[Candle]:
+ candles: list[Candle] = []
+ for row in rows or []:
+ if len(row) < 7:
+ continue
+ candles.append(
+ Candle(
+ timestamp=int(row[0]),
+ open=_float(row[1]),
+ high=_float(row[2]),
+ low=_float(row[3]),
+ close=_float(row[4]),
+ volume=_float(row[5]),
+ turnover=_float(row[6]),
+ )
+ )
+ candles.sort(key=lambda item: item.timestamp)
+ return candles
+
+
+def _float(value: Any, default: float = 0.0) -> float:
+ try:
+ return float(value)
+ except (TypeError, ValueError):
+ return default
+
+
def _clamp(value: float, low: float, high: float) -> float:
return max(low, min(high, value))
+def _sigmoid(value: float) -> float:
+ if value >= 40:
+ return 1.0
+ if value <= -40:
+ return 0.0
+ return 1 / (1 + math.exp(-value))
+
+
def _round_nested(value: Any) -> Any:
if isinstance(value, list):
return [_round_nested(item) for item in value]
@@ -580,6 +883,18 @@ def _strings(raw: str) -> list[str]:
return [item.strip().lower() for item in raw.split(",") if item.strip()]
+def _horizons(raw: str, decision_horizon: int) -> list[int]:
+ values = []
+ for value in _ints(raw or ""):
+ if 1 <= value <= 96 and value not in values:
+ values.append(value)
+ decision_horizon = max(1, min(96, int(decision_horizon)))
+ if decision_horizon not in values:
+ values.append(decision_horizon)
+ values.sort()
+ return values
+
+
def _feature_names_arg(raw: str) -> list[str]:
names = [item.strip() for item in raw.split(",") if item.strip()]
return names or list(DEFAULT_TORCH_FEATURES)