diff --git a/.env.example b/.env.example index c85a9a2..41ad5fb 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index f47ba94..e3228a8 100644 --- a/README.md +++ b/README.md @@ -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: . - Investopedia отдельно указывает, что automated trading systems задают правила entry/exit/money management, но требуют мониторинга и несут риск mechanical failures и over-optimization: . - QuantInsti описывает типовой путь разработки: стратегия, backtesting, paper trading, затем live trading, плюс GUI, order management и risk management: . -- Документация `statsmodels` описывает ARIMA как общий интерфейс для AR/MA/ARMA/ARIMA/SARIMA-моделей; в боте используется легкий AR(1)/AR(3) вариант без добавления тяжелой зависимости `statsmodels`: . -- Документация `arch` описывает GARCH(p,q) как модель для прогнозирования волатильности; в боте используется фиксированная GARCH(1,1)-подобная рекурсия без MLE-оценки параметров, чтобы сохранить легкий runtime на Raspberry Pi: . -- RiskMetrics описывает EWMA-подход к оценке волатильности через коэффициент затухания; в боте `TIME_SERIES_EWMA_LAMBDA=0.94` используется как настраиваемое значение по умолчанию: . - Hochreiter и Schmidhuber описали LSTM как recurrent neural network architecture для последовательностей; обучение LSTM/GRU в проекте выполняется локально через PyTorch, а Raspberry Pi исполняет только экспортированные JSON-веса без PyTorch runtime: . Я не могу подтвердить, что эта стратегия будет прибыльной. Источники выше описывают технические свойства и риски автоматической торговли, но не гарантируют прибыль. @@ -77,7 +74,7 @@ Dashboard: --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 diff --git a/crypto_spot_bot/config.py b/crypto_spot_bot/config.py index abe5491..91fe084 100644 --- a/crypto_spot_bot/config.py +++ b/crypto_spot_bot/config.py @@ -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), diff --git a/crypto_spot_bot/dashboard.py b/crypto_spot_bot/dashboard.py index 5ceab31..b79f8f6 100644 --- a/crypto_spot_bot/dashboard.py +++ b/crypto_spot_bot/dashboard.py @@ -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 || '-'], diff --git a/crypto_spot_bot/time_series.py b/crypto_spot_bot/time_series.py index f1040bb..2af394a 100644 --- a/crypto_spot_bot/time_series.py +++ b/crypto_spot_bot/time_series.py @@ -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}" diff --git a/tests/conftest.py b/tests/conftest.py index 36fbb90..85c6221 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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, diff --git a/tests/test_time_series.py b/tests/test_time_series.py index 8b90a8d..49baebc 100644 --- a/tests/test_time_series.py +++ b/tests/test_time_series.py @@ -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