Use Torch as the only forecast model
This commit is contained in:
@@ -48,9 +48,7 @@ KELLY_FRACTION=0.25
|
||||
KELLY_MAX_FRACTION=0.20
|
||||
TIME_SERIES_FORECAST_ENABLED=true
|
||||
TIME_SERIES_MIN_CANDLES=120
|
||||
TIME_SERIES_VALIDATION_WINDOW=30
|
||||
TIME_SERIES_FORECAST_HORIZON=3
|
||||
TIME_SERIES_EWMA_LAMBDA=0.94
|
||||
TIME_SERIES_MIN_EDGE_PERCENT=0.04
|
||||
TIME_SERIES_MAX_ADJUSTMENT=0.08
|
||||
TIME_SERIES_LSTM_ENABLED=true
|
||||
|
||||
@@ -15,7 +15,7 @@ Spot-бот для демо-торговли криптовалютой на р
|
||||
- Динамический размер позиции: стратегия считает вход через fractional Kelly по вероятности прогноза, stop/take и издержкам, затем ограничивает сумму через `MIN_POSITION_USDT`..`MAX_POSITION_USDT` и лимиты экспозиции.
|
||||
- Автоматический grid-режим: бот включает grid-входы на боковике, покупает только в нижней части диапазона и выключает grid при падающих/опасных режимах.
|
||||
- Вероятностный rebound-вход: после снижения бот отдельно оценивает стабилизацию, отскок от локального low, RSI, объем и рыночные ограничения; такой вход ограничен меньшим размером позиции.
|
||||
- Прогнозирование временных рядов: walk-forward выбор между `naive`, `drift`, `EWMA`, `AR(1)`, `AR(3)` и экспортированными PyTorch `LSTM/GRU`-моделями для ожидаемой доходности плюс EWMA/GARCH-like прогноз волатильности. Прогноз влияет и на новые покупки, и на раннюю продажу при ухудшении ожидаемого движения.
|
||||
- Прогнозирование временных рядов: только экспортированная PyTorch `LSTM/GRU`-модель для ожидаемой доходности и оценки неопределенности по validation MAE. Встроенные не-torch fallback-прогнозы удалены; если валидного torch-артефакта нет, прогноз для пары недоступен.
|
||||
- Защитные блокировки входа: явно отрицательные LONG-шаблоны и setups с сильной отрицательной статистикой обучения запрещают новые покупки.
|
||||
- Быстрый режим торговли: отдельный короткий интервал цикла, короткий cooldown после выхода и лимит новых входов в минуту; выходы по риску этим лимитом не блокируются.
|
||||
- Веб-dashboard на русском: equity, cash, PnL, позиции, сделки, сигналы, события, свечные графики, переключатель быстрой торговли и индикаторы работы обучения.
|
||||
@@ -43,9 +43,6 @@ Live market orders используют `/v5/order/create`; Bybit докумен
|
||||
- Investopedia перечисляет важные свойства algo trading software: real-time market data, low latency, configurability, backtesting, broker/exchange integration, fees/costs и APIs: <https://www.investopedia.com/articles/active-trading/090815/picking-right-algorithmic-trading-software.asp>.
|
||||
- Investopedia отдельно указывает, что automated trading systems задают правила entry/exit/money management, но требуют мониторинга и несут риск mechanical failures и over-optimization: <https://www.investopedia.com/articles/trading/11/automated-trading-systems.asp>.
|
||||
- QuantInsti описывает типовой путь разработки: стратегия, backtesting, paper trading, затем live trading, плюс GUI, order management и risk management: <https://www.quantinsti.com/articles/automated-trading-system/>.
|
||||
- Документация `statsmodels` описывает ARIMA как общий интерфейс для AR/MA/ARMA/ARIMA/SARIMA-моделей; в боте используется легкий AR(1)/AR(3) вариант без добавления тяжелой зависимости `statsmodels`: <https://www.statsmodels.org/stable/generated/statsmodels.tsa.arima.model.ARIMA.html>.
|
||||
- Документация `arch` описывает GARCH(p,q) как модель для прогнозирования волатильности; в боте используется фиксированная GARCH(1,1)-подобная рекурсия без MLE-оценки параметров, чтобы сохранить легкий runtime на Raspberry Pi: <https://arch.readthedocs.io/en/stable/univariate/univariate_volatility_forecasting.html>.
|
||||
- RiskMetrics описывает EWMA-подход к оценке волатильности через коэффициент затухания; в боте `TIME_SERIES_EWMA_LAMBDA=0.94` используется как настраиваемое значение по умолчанию: <https://www.msci.com/documents/10199/d0905614-2771-46dc-b000-1a033146586a>.
|
||||
- Hochreiter и Schmidhuber описали LSTM как recurrent neural network architecture для последовательностей; обучение LSTM/GRU в проекте выполняется локально через PyTorch, а Raspberry Pi исполняет только экспортированные JSON-веса без PyTorch runtime: <https://direct.mit.edu/neco/article/9/8/1735/6109/Long-Short-Term-Memory>.
|
||||
|
||||
Я не могу подтвердить, что эта стратегия будет прибыльной. Источники выше описывают технические свойства и риски автоматической торговли, но не гарантируют прибыль.
|
||||
@@ -77,7 +74,7 @@ Dashboard: <http://127.0.0.1:8787/>
|
||||
--epochs 60
|
||||
```
|
||||
|
||||
Файл из `TIME_SERIES_LSTM_MODEL_PATH` читается ботом автоматически. Экспортированные модели появляются в dashboard как `PyTorch LSTM` или `PyTorch GRU`; старый легкий reservoir LSTM-кандидат удален и больше не участвует в выборе модели.
|
||||
Файл из `TIME_SERIES_LSTM_MODEL_PATH` читается ботом автоматически. Экспортированные модели появляются в dashboard как `PyTorch LSTM` или `PyTorch GRU`; старый легкий reservoir LSTM-кандидат и все встроенные не-torch прогнозы удалены. Если валидной PyTorch модели для пары нет, бот не подставляет fallback-прогноз.
|
||||
|
||||
Автопереобучение на Windows запускает PyTorch trainer, пишет лог в `runtime/torch_retrain.log` и защищается от параллельных запусков:
|
||||
|
||||
@@ -142,9 +139,7 @@ KELLY_FRACTION=0.25
|
||||
KELLY_MAX_FRACTION=0.20
|
||||
TIME_SERIES_FORECAST_ENABLED=true
|
||||
TIME_SERIES_MIN_CANDLES=120
|
||||
TIME_SERIES_VALIDATION_WINDOW=30
|
||||
TIME_SERIES_FORECAST_HORIZON=3
|
||||
TIME_SERIES_EWMA_LAMBDA=0.94
|
||||
TIME_SERIES_MIN_EDGE_PERCENT=0.04
|
||||
TIME_SERIES_MAX_ADJUSTMENT=0.08
|
||||
TIME_SERIES_LSTM_ENABLED=true
|
||||
|
||||
@@ -98,9 +98,7 @@ class Settings:
|
||||
kelly_max_fraction: float
|
||||
time_series_forecast_enabled: bool
|
||||
time_series_min_candles: int
|
||||
time_series_validation_window: int
|
||||
time_series_forecast_horizon: int
|
||||
time_series_ewma_lambda: float
|
||||
time_series_min_edge_percent: float
|
||||
time_series_max_adjustment: float
|
||||
time_series_lstm_enabled: bool
|
||||
@@ -222,9 +220,7 @@ def load_settings(env_file: str | Path | None = None) -> Settings:
|
||||
kelly_max_fraction=_float_env("KELLY_MAX_FRACTION", 0.20),
|
||||
time_series_forecast_enabled=_bool_env("TIME_SERIES_FORECAST_ENABLED", True),
|
||||
time_series_min_candles=_int_env("TIME_SERIES_MIN_CANDLES", 120),
|
||||
time_series_validation_window=_int_env("TIME_SERIES_VALIDATION_WINDOW", 30),
|
||||
time_series_forecast_horizon=_int_env("TIME_SERIES_FORECAST_HORIZON", 3),
|
||||
time_series_ewma_lambda=_float_env("TIME_SERIES_EWMA_LAMBDA", 0.94),
|
||||
time_series_min_edge_percent=_float_env("TIME_SERIES_MIN_EDGE_PERCENT", 0.04),
|
||||
time_series_max_adjustment=_float_env("TIME_SERIES_MAX_ADJUSTMENT", 0.08),
|
||||
time_series_lstm_enabled=_bool_env("TIME_SERIES_LSTM_ENABLED", True),
|
||||
|
||||
@@ -215,9 +215,7 @@ def _safe_config(settings: Settings) -> dict[str, Any]:
|
||||
"kelly_max_fraction": settings.kelly_max_fraction,
|
||||
"time_series_forecast_enabled": settings.time_series_forecast_enabled,
|
||||
"time_series_min_candles": settings.time_series_min_candles,
|
||||
"time_series_validation_window": settings.time_series_validation_window,
|
||||
"time_series_forecast_horizon": settings.time_series_forecast_horizon,
|
||||
"time_series_ewma_lambda": settings.time_series_ewma_lambda,
|
||||
"time_series_min_edge_percent": settings.time_series_min_edge_percent,
|
||||
"time_series_max_adjustment": settings.time_series_max_adjustment,
|
||||
"time_series_lstm_enabled": settings.time_series_lstm_enabled,
|
||||
@@ -257,16 +255,19 @@ def _time_series_model_artifact(settings: Settings) -> dict[str, Any]:
|
||||
"symbol_count": 0,
|
||||
"models": [],
|
||||
}
|
||||
artifact_type = str(data.get("type", "")).strip()
|
||||
symbols = data.get("symbols")
|
||||
rows = list(symbols.values()) if isinstance(symbols, dict) else []
|
||||
models = sorted(
|
||||
{
|
||||
_forecast_model_label(str(row.get("model", row.get("architecture", "lstm"))))
|
||||
_forecast_model_label(
|
||||
str(row.get("model", row.get("architecture", "lstm"))),
|
||||
torch_artifact=artifact_type == "pytorch_recurrent_forecaster",
|
||||
)
|
||||
for row in rows
|
||||
if isinstance(row, dict)
|
||||
}
|
||||
)
|
||||
artifact_type = str(data.get("type", "")).strip()
|
||||
if artifact_type != "pytorch_recurrent_forecaster":
|
||||
return {
|
||||
"available": False,
|
||||
@@ -286,16 +287,16 @@ def _time_series_model_artifact(settings: Settings) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _forecast_model_label(model: str) -> str:
|
||||
def _forecast_model_label(model: str, *, torch_artifact: bool = False) -> str:
|
||||
normalized = model.strip().lower()
|
||||
if normalized == "torch_lstm":
|
||||
if normalized in {"torch_lstm", "lstm"} and torch_artifact:
|
||||
return "PyTorch LSTM"
|
||||
if normalized == "torch_gru":
|
||||
if normalized in {"torch_gru", "gru"} and torch_artifact:
|
||||
return "PyTorch GRU"
|
||||
if normalized == "lstm":
|
||||
return "устаревший LSTM"
|
||||
return "устаревший артефакт"
|
||||
if normalized == "gru":
|
||||
return "GRU"
|
||||
return "устаревший артефакт"
|
||||
return model
|
||||
|
||||
|
||||
@@ -746,12 +747,6 @@ HTML = r"""
|
||||
const names = {
|
||||
torch_lstm: 'PyTorch LSTM',
|
||||
torch_gru: 'PyTorch GRU',
|
||||
lstm: 'Устаревший LSTM',
|
||||
naive: 'Baseline',
|
||||
drift: 'Drift',
|
||||
ewma: 'EWMA',
|
||||
ar1: 'AR(1)',
|
||||
ar3: 'AR(3)',
|
||||
none: '-'
|
||||
};
|
||||
return names[key] || String(model || '-');
|
||||
@@ -937,7 +932,6 @@ HTML = r"""
|
||||
['Kelly размер', `${yesNo(config.kelly_sizing_enabled)} · ${num(config.kelly_fraction, 2)}x · max ${num((config.kelly_max_fraction || 0) * 100, 1)}%`],
|
||||
['Прогноз временных рядов', yesNo(config.time_series_forecast_enabled)],
|
||||
['Модельный горизонт', `${config.time_series_forecast_horizon} свечи`],
|
||||
['Walk-forward окно', `${config.time_series_validation_window} свечей`],
|
||||
['Мин. edge прогноза', `${num(config.time_series_min_edge_percent, 3)}%`],
|
||||
['Нейропрогноз', modelArtifactSummary(config)],
|
||||
['Файл модели', config.time_series_lstm_model_path || '-'],
|
||||
|
||||
+51
-197
@@ -44,62 +44,52 @@ class TimeSeriesForecaster:
|
||||
closes = [float(candle.close) for candle in candles if candle.close > 0]
|
||||
min_candles = max(30, self.settings.time_series_min_candles)
|
||||
if len(closes) < min_candles:
|
||||
return _empty_forecast(True, "недостаточно свечей для прогноза")
|
||||
return _empty_forecast(True, "недостаточно свечей для PyTorch прогноза")
|
||||
returns = _log_returns(closes)
|
||||
if len(returns) < 20:
|
||||
return _empty_forecast(True, "недостаточно доходностей для прогноза")
|
||||
return _empty_forecast(True, "недостаточно доходностей для PyTorch прогноза")
|
||||
|
||||
artifact = self._load_lstm_artifact()
|
||||
model = _torch_recurrent_model_name(symbol, artifact)
|
||||
if not model or not _can_use_torch_recurrent(returns, symbol, artifact):
|
||||
return _empty_forecast(True, "нет валидной PyTorch LSTM/GRU модели для пары")
|
||||
entry = _torch_recurrent_entry(symbol, artifact)
|
||||
prediction = _torch_recurrent_predict(returns, symbol, artifact)
|
||||
if entry is None or prediction is None:
|
||||
return _empty_forecast(True, "PyTorch LSTM/GRU модель не смогла построить прогноз")
|
||||
|
||||
validation_window = min(
|
||||
max(8, self.settings.time_series_validation_window),
|
||||
max(8, len(returns) // 3),
|
||||
)
|
||||
lstm_artifact = self._load_lstm_artifact()
|
||||
candidates = _validate_candidates(returns, validation_window, self.settings, symbol, lstm_artifact)
|
||||
best = min(candidates, key=lambda item: item["mae"])
|
||||
baseline = next(item for item in candidates if item["model"] == "naive")
|
||||
latest_prediction = _predict_next_return(best["model"], returns, self.settings, symbol, lstm_artifact)
|
||||
horizon = max(1, self.settings.time_series_forecast_horizon)
|
||||
expected_return = latest_prediction * horizon
|
||||
expected_return = prediction * horizon
|
||||
expected_price = closes[-1] * math.exp(expected_return)
|
||||
|
||||
ewma_vol = _ewma_volatility(returns, self.settings.time_series_ewma_lambda)
|
||||
garch_vol = _fixed_garch_volatility(returns)
|
||||
vol_one_step = max(ewma_vol, garch_vol)
|
||||
volatility_percent = vol_one_step * math.sqrt(horizon) * 100
|
||||
model_mae = _torch_validation_mae(entry, returns)
|
||||
baseline_mae = max(_float_entry(entry, "baseline_mae_percent", model_mae * 100) / 100, model_mae)
|
||||
uncertainty_one_step = max(model_mae, _return_scale(returns) * 0.25, 1e-9)
|
||||
uncertainty = uncertainty_one_step * math.sqrt(horizon)
|
||||
volatility_percent = uncertainty * 100
|
||||
expected_return_percent = (math.exp(expected_return) - 1) * 100
|
||||
probability_up = _normal_cdf(expected_return / max(vol_one_step * math.sqrt(horizon), 1e-9))
|
||||
baseline_mae = float(baseline["mae"])
|
||||
model_mae = float(best["mae"])
|
||||
skill = (baseline_mae - model_mae) / baseline_mae if baseline_mae > 0 else 0.0
|
||||
skill = _clamp(skill, -1.0, 1.0)
|
||||
probability_up = _normal_cdf(expected_return / max(uncertainty, 1e-9))
|
||||
skill = _clamp(_float_entry(entry, "skill", 0.0), -1.0, 1.0)
|
||||
min_edge = max(0.0, self.settings.time_series_min_edge_percent)
|
||||
usable_skill = skill > 0.02 and best["model"] != "naive"
|
||||
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,
|
||||
usable_skill=usable_skill,
|
||||
)
|
||||
block_entry = bool(
|
||||
usable_skill
|
||||
and expected_return_percent <= -min_edge
|
||||
and probability_up <= 0.45
|
||||
)
|
||||
block_entry = bool(expected_return_percent <= -min_edge and probability_up <= 0.45)
|
||||
reason = _reason(
|
||||
model=best["model"],
|
||||
model=model,
|
||||
expected_return_percent=expected_return_percent,
|
||||
probability_up=probability_up,
|
||||
skill=skill,
|
||||
block_entry=block_entry,
|
||||
usable_skill=usable_skill,
|
||||
)
|
||||
return TimeSeriesForecast(
|
||||
enabled=True,
|
||||
usable=True,
|
||||
model=best["model"],
|
||||
volatility_model="max(EWMA,GARCH-like)",
|
||||
model=model,
|
||||
volatility_model="torch validation MAE",
|
||||
expected_return_percent=round(expected_return_percent, 4),
|
||||
expected_price=round(expected_price, 8),
|
||||
volatility_percent=round(volatility_percent, 4),
|
||||
@@ -111,10 +101,7 @@ class TimeSeriesForecaster:
|
||||
skill=round(skill, 4),
|
||||
horizon=horizon,
|
||||
reason=reason,
|
||||
candidates=[
|
||||
{"model": item["model"], "mae_percent": round(float(item["mae"]) * 100, 4)}
|
||||
for item in sorted(candidates, key=lambda item: item["mae"])
|
||||
],
|
||||
candidates=[{"model": model, "mae_percent": round(model_mae * 100, 4)}],
|
||||
)
|
||||
|
||||
def _load_lstm_artifact(self) -> dict[str, Any]:
|
||||
@@ -162,85 +149,8 @@ def _log_returns(closes: list[float]) -> list[float]:
|
||||
return [math.log(closes[index] / closes[index - 1]) for index in range(1, len(closes))]
|
||||
|
||||
|
||||
def _validate_candidates(
|
||||
returns: list[float],
|
||||
validation_window: int,
|
||||
settings: Settings,
|
||||
symbol: str | None = None,
|
||||
lstm_artifact: dict[str, Any] | None = None,
|
||||
) -> list[dict[str, float | str]]:
|
||||
models = ["naive", "drift", "ewma", "ar1", "ar3"]
|
||||
torch_model = _torch_recurrent_model_name(symbol, lstm_artifact or {})
|
||||
if torch_model and _can_use_torch_recurrent(returns, symbol, lstm_artifact or {}):
|
||||
models.append(torch_model)
|
||||
rows: list[dict[str, float | str]] = []
|
||||
start = max(8, len(returns) - validation_window)
|
||||
for model in models:
|
||||
errors: list[float] = []
|
||||
for index in range(start, len(returns)):
|
||||
history = returns[:index]
|
||||
if len(history) < 8:
|
||||
continue
|
||||
predicted = _predict_next_return(model, history, settings, symbol, lstm_artifact)
|
||||
errors.append(abs(predicted - returns[index]))
|
||||
mae = sum(errors) / len(errors) if errors else 1e9
|
||||
rows.append({"model": model, "mae": mae})
|
||||
return rows
|
||||
|
||||
|
||||
def _predict_next_return(
|
||||
model: str,
|
||||
returns: list[float],
|
||||
settings: Settings | None = None,
|
||||
symbol: str | None = None,
|
||||
lstm_artifact: dict[str, Any] | None = None,
|
||||
) -> float:
|
||||
if model == "naive":
|
||||
return 0.0
|
||||
if model == "drift":
|
||||
window = returns[-24:] if len(returns) >= 24 else returns
|
||||
return sum(window) / len(window) if window else 0.0
|
||||
if model == "ewma":
|
||||
return _ewma_mean(returns, 0.82)
|
||||
if model == "ar1":
|
||||
return _ar_predict(returns, 1)
|
||||
if model == "ar3":
|
||||
return _ar_predict(returns, 3)
|
||||
if model in {"torch_lstm", "torch_gru"}:
|
||||
return _torch_recurrent_predict(returns, symbol, lstm_artifact or {})
|
||||
return 0.0
|
||||
|
||||
|
||||
def _ewma_mean(values: list[float], decay: float) -> float:
|
||||
if not values:
|
||||
return 0.0
|
||||
estimate = values[0]
|
||||
alpha = 1 - _clamp(decay, 0.01, 0.99)
|
||||
for value in values[1:]:
|
||||
estimate = alpha * value + (1 - alpha) * estimate
|
||||
return estimate
|
||||
|
||||
|
||||
def _ar_predict(returns: list[float], lag_count: int) -> float:
|
||||
if len(returns) <= lag_count + 6:
|
||||
return _predict_next_return("drift", returns)
|
||||
rows: list[list[float]] = []
|
||||
targets: list[float] = []
|
||||
for index in range(lag_count, len(returns)):
|
||||
rows.append([1.0] + [returns[index - lag] for lag in range(1, lag_count + 1)])
|
||||
targets.append(returns[index])
|
||||
coeffs = _ols(rows, targets)
|
||||
if not coeffs:
|
||||
return _predict_next_return("drift", returns)
|
||||
features = [1.0] + [returns[-lag] for lag in range(1, lag_count + 1)]
|
||||
prediction = sum(coeff * feature for coeff, feature in zip(coeffs, features))
|
||||
recent_abs = sorted(abs(value) for value in returns[-48:]) if len(returns) >= 8 else [0.01]
|
||||
cap = max(recent_abs[int(len(recent_abs) * 0.9)], 0.0002)
|
||||
return _clamp(prediction, -cap, cap)
|
||||
|
||||
|
||||
def _torch_recurrent_model_name(symbol: str | None, lstm_artifact: dict[str, Any]) -> str | None:
|
||||
entry = _torch_recurrent_entry(symbol, lstm_artifact)
|
||||
def _torch_recurrent_model_name(symbol: str | None, artifact: dict[str, Any]) -> str | None:
|
||||
entry = _torch_recurrent_entry(symbol, artifact)
|
||||
if not entry:
|
||||
return None
|
||||
architecture = str(entry.get("architecture", "")).strip().lower()
|
||||
@@ -250,11 +160,13 @@ def _torch_recurrent_model_name(symbol: str | None, lstm_artifact: dict[str, Any
|
||||
return model if model in {"torch_lstm", "torch_gru"} else None
|
||||
|
||||
|
||||
def _torch_recurrent_entry(symbol: str | None, lstm_artifact: dict[str, Any]) -> dict[str, Any] | None:
|
||||
symbols = lstm_artifact.get("symbols")
|
||||
def _torch_recurrent_entry(symbol: str | None, artifact: dict[str, Any]) -> dict[str, Any] | None:
|
||||
if artifact.get("type") != "pytorch_recurrent_forecaster":
|
||||
return None
|
||||
symbols = artifact.get("symbols")
|
||||
entry = symbols.get(symbol.upper()) if symbol and isinstance(symbols, dict) else None
|
||||
if not isinstance(entry, dict):
|
||||
default = lstm_artifact.get("default")
|
||||
default = artifact.get("default")
|
||||
entry = default if isinstance(default, dict) else None
|
||||
if not isinstance(entry, dict):
|
||||
return None
|
||||
@@ -263,8 +175,8 @@ def _torch_recurrent_entry(symbol: str | None, lstm_artifact: dict[str, Any]) ->
|
||||
return entry
|
||||
|
||||
|
||||
def _can_use_torch_recurrent(returns: list[float], symbol: str | None, lstm_artifact: dict[str, Any]) -> bool:
|
||||
entry = _torch_recurrent_entry(symbol, lstm_artifact)
|
||||
def _can_use_torch_recurrent(returns: list[float], symbol: str | None, artifact: dict[str, Any]) -> bool:
|
||||
entry = _torch_recurrent_entry(symbol, artifact)
|
||||
if not entry:
|
||||
return False
|
||||
lookback = int(_clamp(_float_entry(entry, "lookback", 0.0), 4.0, 512.0))
|
||||
@@ -276,12 +188,12 @@ def _can_use_torch_recurrent(returns: list[float], symbol: str | None, lstm_arti
|
||||
def _torch_recurrent_predict(
|
||||
returns: list[float],
|
||||
symbol: str | None,
|
||||
lstm_artifact: dict[str, Any],
|
||||
) -> float:
|
||||
entry = _torch_recurrent_entry(symbol, lstm_artifact)
|
||||
model_name = _torch_recurrent_model_name(symbol, lstm_artifact)
|
||||
artifact: dict[str, Any],
|
||||
) -> float | None:
|
||||
entry = _torch_recurrent_entry(symbol, artifact)
|
||||
model_name = _torch_recurrent_model_name(symbol, artifact)
|
||||
if not entry or not model_name:
|
||||
return _predict_next_return("drift", returns)
|
||||
return None
|
||||
lookback = int(_clamp(_float_entry(entry, "lookback", 0.0), 4.0, 512.0))
|
||||
hidden_size = int(_clamp(_float_entry(entry, "hidden_size", 0.0), 1.0, 512.0))
|
||||
num_layers = int(_clamp(_float_entry(entry, "num_layers", 1.0), 1.0, 8.0))
|
||||
@@ -289,7 +201,7 @@ def _torch_recurrent_predict(
|
||||
scale = max(_float_entry(entry, "scale", _return_scale(returns)), 1e-8)
|
||||
clip = _clamp(_float_entry(entry, "clip", 8.0), 1.0, 50.0)
|
||||
if len(returns) < lookback:
|
||||
return _predict_next_return("drift", returns)
|
||||
return None
|
||||
|
||||
normalized = [_clamp((value - mean) / scale, -clip, clip) for value in returns[-lookback:]]
|
||||
try:
|
||||
@@ -301,17 +213,17 @@ def _torch_recurrent_predict(
|
||||
num_layers=num_layers,
|
||||
)
|
||||
if hidden is None:
|
||||
return _predict_next_return("drift", returns)
|
||||
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:
|
||||
return _predict_next_return("drift", returns)
|
||||
return None
|
||||
normalized_prediction = sum(weight * value for weight, value in zip(head_weight, hidden)) + head_bias
|
||||
if not math.isfinite(normalized_prediction):
|
||||
return _predict_next_return("drift", returns)
|
||||
return None
|
||||
prediction = _clamp(normalized_prediction, -clip, clip) * scale + mean
|
||||
except (IndexError, KeyError, TypeError, ValueError, OverflowError):
|
||||
return _predict_next_return("drift", returns)
|
||||
return None
|
||||
|
||||
recent_abs = sorted(abs(value) for value in returns[-48:]) if len(returns) >= 8 else [0.01]
|
||||
cap = max(recent_abs[int(len(recent_abs) * 0.9)], 0.0002)
|
||||
@@ -440,6 +352,13 @@ def _torch_gate_values(
|
||||
return gates
|
||||
|
||||
|
||||
def _torch_validation_mae(entry: dict[str, Any], returns: list[float]) -> float:
|
||||
mae_percent = _float_entry(entry, "validation_mae_percent", 0.0)
|
||||
if mae_percent > 0:
|
||||
return mae_percent / 100
|
||||
return _return_scale(returns)
|
||||
|
||||
|
||||
def _float_entry(data: dict[str, Any], key: str, default: float) -> float:
|
||||
value = data.get(key)
|
||||
if isinstance(value, (int, float)):
|
||||
@@ -486,65 +405,6 @@ def _sigmoid(value: float) -> float:
|
||||
return 1 / (1 + math.exp(-value))
|
||||
|
||||
|
||||
def _ols(rows: list[list[float]], targets: list[float], ridge: float = 1e-8) -> list[float] | None:
|
||||
if not rows:
|
||||
return None
|
||||
columns = len(rows[0])
|
||||
xtx = [[0.0 for _ in range(columns)] for _ in range(columns)]
|
||||
xty = [0.0 for _ in range(columns)]
|
||||
for row, target in zip(rows, targets):
|
||||
for i in range(columns):
|
||||
xty[i] += row[i] * target
|
||||
for j in range(columns):
|
||||
xtx[i][j] += row[i] * row[j]
|
||||
for i in range(columns):
|
||||
xtx[i][i] += ridge
|
||||
return _solve_linear_system(xtx, xty)
|
||||
|
||||
|
||||
def _solve_linear_system(matrix: list[list[float]], vector: list[float]) -> list[float] | None:
|
||||
size = len(vector)
|
||||
augmented = [row[:] + [vector[index]] for index, row in enumerate(matrix)]
|
||||
for col in range(size):
|
||||
pivot = max(range(col, size), key=lambda row: abs(augmented[row][col]))
|
||||
if abs(augmented[pivot][col]) < 1e-12:
|
||||
return None
|
||||
augmented[col], augmented[pivot] = augmented[pivot], augmented[col]
|
||||
pivot_value = augmented[col][col]
|
||||
for item in range(col, size + 1):
|
||||
augmented[col][item] /= pivot_value
|
||||
for row in range(size):
|
||||
if row == col:
|
||||
continue
|
||||
factor = augmented[row][col]
|
||||
for item in range(col, size + 1):
|
||||
augmented[row][item] -= factor * augmented[col][item]
|
||||
return [augmented[row][size] for row in range(size)]
|
||||
|
||||
|
||||
def _ewma_volatility(returns: list[float], decay: float) -> float:
|
||||
if not returns:
|
||||
return 0.0
|
||||
decay = _clamp(decay, 0.80, 0.995)
|
||||
variance = returns[0] * returns[0]
|
||||
for value in returns[1:]:
|
||||
variance = decay * variance + (1 - decay) * value * value
|
||||
return math.sqrt(max(variance, 0.0))
|
||||
|
||||
|
||||
def _fixed_garch_volatility(returns: list[float]) -> float:
|
||||
if not returns:
|
||||
return 0.0
|
||||
long_variance = sum(value * value for value in returns) / len(returns)
|
||||
alpha = 0.08
|
||||
beta = 0.90
|
||||
omega = max(1e-12, (1 - alpha - beta) * long_variance)
|
||||
variance = long_variance
|
||||
for value in returns:
|
||||
variance = omega + alpha * value * value + beta * variance
|
||||
return math.sqrt(max(variance, 0.0))
|
||||
|
||||
|
||||
def _confidence_adjustment(
|
||||
*,
|
||||
expected_return_percent: float,
|
||||
@@ -552,10 +412,7 @@ def _confidence_adjustment(
|
||||
skill: float,
|
||||
min_edge: float,
|
||||
max_adjustment: float,
|
||||
usable_skill: bool,
|
||||
) -> float:
|
||||
if not usable_skill:
|
||||
return 0.0
|
||||
edge = abs(expected_return_percent) - min_edge
|
||||
if edge <= 0:
|
||||
return 0.0
|
||||
@@ -564,7 +421,7 @@ def _confidence_adjustment(
|
||||
return 0.0
|
||||
strength = _clamp(edge / max(min_edge, 0.05), 0.0, 1.0)
|
||||
probability_strength = _clamp(abs(probability_up - 0.5) / 0.25, 0.0, 1.0)
|
||||
skill_strength = _clamp(skill / 0.18, 0.0, 1.0)
|
||||
skill_strength = _clamp((skill + 0.03) / 0.18, 0.25, 1.0)
|
||||
return direction * _clamp(max_adjustment, 0.0, 0.18) * strength * probability_strength * skill_strength
|
||||
|
||||
|
||||
@@ -575,10 +432,7 @@ def _reason(
|
||||
probability_up: float,
|
||||
skill: float,
|
||||
block_entry: bool,
|
||||
usable_skill: bool,
|
||||
) -> str:
|
||||
if not usable_skill:
|
||||
return f"модель {model} не лучше baseline на walk-forward проверке"
|
||||
if block_entry:
|
||||
return f"модель {model}: ожидаемое движение вниз {expected_return_percent:.3f}%, P(рост)={probability_up:.2f}"
|
||||
return f"модель {model}: прогноз {expected_return_percent:.3f}%, P(рост)={probability_up:.2f}, skill={skill:.3f}"
|
||||
|
||||
@@ -64,9 +64,7 @@ def make_settings():
|
||||
kelly_max_fraction=0.20,
|
||||
time_series_forecast_enabled=True,
|
||||
time_series_min_candles=120,
|
||||
time_series_validation_window=30,
|
||||
time_series_forecast_horizon=3,
|
||||
time_series_ewma_lambda=0.94,
|
||||
time_series_min_edge_percent=0.04,
|
||||
time_series_max_adjustment=0.08,
|
||||
time_series_lstm_enabled=True,
|
||||
|
||||
+66
-50
@@ -34,11 +34,53 @@ def _candles_from_returns(returns: list[float]) -> list[Candle]:
|
||||
return candles
|
||||
|
||||
|
||||
def test_time_series_forecaster_selects_positive_predictive_model(make_settings, tmp_path) -> None:
|
||||
def _write_torch_gru_artifact(
|
||||
path,
|
||||
*,
|
||||
head_bias: float,
|
||||
validation_mae_percent: float = 0.02,
|
||||
baseline_mae_percent: float = 0.08,
|
||||
skill: float = 0.2,
|
||||
) -> None:
|
||||
hidden_size = 2
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"version": 2,
|
||||
"type": "pytorch_recurrent_forecaster",
|
||||
"symbols": {
|
||||
"BTCUSDT": {
|
||||
"model": "torch_gru",
|
||||
"architecture": "gru",
|
||||
"lookback": 8,
|
||||
"hidden_size": hidden_size,
|
||||
"num_layers": 1,
|
||||
"mean": 0.0,
|
||||
"scale": 0.001,
|
||||
"clip": 8.0,
|
||||
"validation_mae_percent": validation_mae_percent,
|
||||
"baseline_mae_percent": baseline_mae_percent,
|
||||
"skill": skill,
|
||||
"state_dict": {
|
||||
"weight_ih_l0": [[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],
|
||||
"head_bias": head_bias,
|
||||
},
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def test_time_series_forecaster_requires_torch_artifact(make_settings, tmp_path) -> None:
|
||||
settings = make_settings(
|
||||
tmp_path,
|
||||
time_series_min_candles=80,
|
||||
time_series_validation_window=24,
|
||||
time_series_forecast_horizon=3,
|
||||
)
|
||||
returns = []
|
||||
@@ -47,32 +89,32 @@ def test_time_series_forecaster_selects_positive_predictive_model(make_settings,
|
||||
value = 0.00025 + value * 0.55
|
||||
returns.append(value)
|
||||
|
||||
forecast = TimeSeriesForecaster(settings).forecast(_candles_from_returns(returns))
|
||||
forecast = TimeSeriesForecaster(settings).forecast(_candles_from_returns(returns), symbol="BTCUSDT")
|
||||
|
||||
assert forecast.usable is True
|
||||
assert forecast.model != "naive"
|
||||
assert forecast.expected_return_percent > 0
|
||||
assert forecast.probability_up > 0.5
|
||||
assert forecast.usable is False
|
||||
assert forecast.model == "none"
|
||||
assert forecast.candidates == []
|
||||
assert "PyTorch" in forecast.reason
|
||||
|
||||
|
||||
def test_time_series_forecaster_blocks_negative_edge(make_settings, tmp_path) -> None:
|
||||
def test_time_series_forecaster_blocks_negative_torch_edge(make_settings, tmp_path) -> None:
|
||||
artifact_path = tmp_path / "lstm_forecaster.json"
|
||||
_write_torch_gru_artifact(artifact_path, head_bias=-0.8, validation_mae_percent=0.01)
|
||||
settings = make_settings(
|
||||
tmp_path,
|
||||
time_series_min_candles=80,
|
||||
time_series_validation_window=24,
|
||||
time_series_forecast_horizon=3,
|
||||
time_series_min_edge_percent=0.03,
|
||||
time_series_lstm_model_path=artifact_path,
|
||||
)
|
||||
returns = []
|
||||
value = -0.0003
|
||||
for _ in range(140):
|
||||
value = -0.00025 + value * 0.55
|
||||
returns.append(value)
|
||||
returns = [0.00015 if index % 4 else -0.00005 for index in range(140)]
|
||||
|
||||
forecast = TimeSeriesForecaster(settings).forecast(_candles_from_returns(returns))
|
||||
forecast = TimeSeriesForecaster(settings).forecast(_candles_from_returns(returns), symbol="BTCUSDT")
|
||||
|
||||
assert forecast.usable is True
|
||||
assert forecast.model == "torch_gru"
|
||||
assert forecast.expected_return_percent < 0
|
||||
assert forecast.probability_up < 0.45
|
||||
assert forecast.block_entry is True
|
||||
|
||||
|
||||
@@ -93,7 +135,6 @@ def test_time_series_forecaster_ignores_legacy_lstm_artifact(make_settings, tmp_
|
||||
settings = make_settings(
|
||||
tmp_path,
|
||||
time_series_min_candles=80,
|
||||
time_series_validation_window=20,
|
||||
time_series_lstm_enabled=True,
|
||||
time_series_lstm_model_path=artifact_path,
|
||||
)
|
||||
@@ -101,46 +142,18 @@ def test_time_series_forecaster_ignores_legacy_lstm_artifact(make_settings, tmp_
|
||||
|
||||
forecast = TimeSeriesForecaster(settings).forecast(_candles_from_returns(returns), symbol="BTCUSDT")
|
||||
|
||||
assert forecast.usable is True
|
||||
assert all(candidate["model"] != "lstm" for candidate in forecast.candidates)
|
||||
assert forecast.usable is False
|
||||
assert forecast.model == "none"
|
||||
assert forecast.candidates == []
|
||||
assert "PyTorch" in forecast.reason
|
||||
|
||||
|
||||
def test_time_series_forecaster_reads_torch_gru_artifact(make_settings, tmp_path) -> None:
|
||||
artifact_path = tmp_path / "lstm_forecaster.json"
|
||||
hidden_size = 2
|
||||
artifact_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"version": 2,
|
||||
"type": "pytorch_recurrent_forecaster",
|
||||
"symbols": {
|
||||
"BTCUSDT": {
|
||||
"model": "torch_gru",
|
||||
"architecture": "gru",
|
||||
"lookback": 8,
|
||||
"hidden_size": hidden_size,
|
||||
"num_layers": 1,
|
||||
"mean": 0.0,
|
||||
"scale": 0.001,
|
||||
"clip": 8.0,
|
||||
"state_dict": {
|
||||
"weight_ih_l0": [[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],
|
||||
"head_bias": 0.2,
|
||||
},
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
_write_torch_gru_artifact(artifact_path, head_bias=0.2)
|
||||
settings = make_settings(
|
||||
tmp_path,
|
||||
time_series_min_candles=80,
|
||||
time_series_validation_window=20,
|
||||
time_series_lstm_enabled=True,
|
||||
time_series_lstm_model_path=artifact_path,
|
||||
)
|
||||
@@ -149,4 +162,7 @@ def test_time_series_forecaster_reads_torch_gru_artifact(make_settings, tmp_path
|
||||
forecast = TimeSeriesForecaster(settings).forecast(_candles_from_returns(returns), symbol="BTCUSDT")
|
||||
|
||||
assert forecast.usable is True
|
||||
assert any(candidate["model"] == "torch_gru" for candidate in forecast.candidates)
|
||||
assert forecast.model == "torch_gru"
|
||||
assert forecast.candidates == [{"model": "torch_gru", "mae_percent": 0.02}]
|
||||
assert forecast.expected_return_percent > 0
|
||||
assert forecast.probability_up > 0.5
|
||||
|
||||
Reference in New Issue
Block a user