From d9c0317675320f6d813c78d5ecbe24401f6750a8 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 20 Jun 2026 21:37:34 +0300 Subject: [PATCH] Clarify forecast model labels in dashboard --- crypto_spot_bot/dashboard.py | 131 ++++++++++++++++++++++++++++++++--- tests/test_dashboard.py | 32 +++++++++ 2 files changed, 155 insertions(+), 8 deletions(-) diff --git a/crypto_spot_bot/dashboard.py b/crypto_spot_bot/dashboard.py index c390d62..415c002 100644 --- a/crypto_spot_bot/dashboard.py +++ b/crypto_spot_bot/dashboard.py @@ -221,6 +221,7 @@ def _safe_config(settings: Settings) -> dict[str, Any]: "time_series_lstm_units": settings.time_series_lstm_units, "time_series_lstm_ridge": settings.time_series_lstm_ridge, "time_series_lstm_model_path": str(settings.time_series_lstm_model_path), + "time_series_model_artifact": _time_series_model_artifact(settings), "stop_loss_percent": settings.stop_loss_percent, "take_profit_percent": settings.take_profit_percent, "trailing_stop_percent": settings.trailing_stop_percent, @@ -235,6 +236,65 @@ def _safe_config(settings: Settings) -> dict[str, Any]: } +def _time_series_model_artifact(settings: Settings) -> dict[str, Any]: + path = settings.time_series_lstm_model_path + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return { + "available": False, + "type": "missing", + "label": "нет файла модели", + "symbol_count": 0, + "models": [], + } + if not isinstance(data, dict): + return { + "available": False, + "type": "invalid", + "label": "файл модели не распознан", + "symbol_count": 0, + "models": [], + } + 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")))) + for row in rows + if isinstance(row, dict) + } + ) + artifact_type = str(data.get("type", "")).strip() + if artifact_type == "pytorch_recurrent_forecaster": + label = "PyTorch LSTM/GRU" + elif artifact_type == "lstm_reservoir_ridge_params": + label = "легкий LSTM fallback" + else: + label = artifact_type or "настройки прогноза" + return { + "available": True, + "type": artifact_type or "unknown", + "label": label, + "created_at": data.get("created_at", ""), + "symbol_count": len(rows), + "models": models, + } + + +def _forecast_model_label(model: str) -> str: + normalized = model.strip().lower() + if normalized == "torch_lstm": + return "PyTorch LSTM" + if normalized == "torch_gru": + return "PyTorch GRU" + if normalized == "lstm": + return "легкий LSTM" + if normalized == "gru": + return "GRU" + return model + + HTML = r""" @@ -677,12 +737,69 @@ HTML = r""" } } + function modelName(model) { + const key = String(model || '').toLowerCase(); + 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 || '-'); + } + + function modelReason(reason) { + return String(reason || '') + .replaceAll('torch_lstm', 'PyTorch LSTM') + .replaceAll('torch_gru', 'PyTorch GRU') + .replaceAll('модель lstm', 'модель легкий LSTM'); + } + + function modelArtifactSummary(config) { + if (!config.time_series_lstm_enabled) { + return 'выкл'; + } + const artifact = config.time_series_model_artifact || {}; + if (!artifact.available) { + return artifact.label || 'нет файла модели'; + } + const parts = [artifact.label || 'модель прогноза']; + if (Number(artifact.symbol_count || 0) > 0) { + parts.push(`${artifact.symbol_count} пар`); + } + if (Array.isArray(artifact.models) && artifact.models.length) { + parts.push(artifact.models.join(', ')); + } + const date = shortDateTime(artifact.created_at); + if (date) { + parts.push(date); + } + return parts.join(' · '); + } + + function shortDateTime(value) { + if (!value) return ''; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ''; + return date.toLocaleString('ru-RU', { + day: '2-digit', + month: '2-digit', + hour: '2-digit', + minute: '2-digit' + }); + } + function forecastHtml(forecast) { if (!forecast || !forecast.usable) { - return `
Прогноз${escapeHtml(forecast?.reason || 'нет данных')}
`; + return `
Прогноз${escapeHtml(modelReason(forecast?.reason || 'нет данных'))}
`; } return `
-
Модель${escapeHtml(forecast.model || '-')}
+
Модель${escapeHtml(modelName(forecast.model || '-'))}
P роста${num((forecast.probability_up || 0) * 100, 1)}%
Ожидание${signedNum(forecast.expected_return_percent, 3)}%
Волат.${num(forecast.volatility_percent, 3)}%
@@ -781,7 +898,7 @@ HTML = r""" const expected = Number(forecast.expected_return_percent); const probability = Number(forecast.probability_up); const skill = Number(forecast.skill); - const model = escapeHtml(forecast.model || '-'); + const model = escapeHtml(modelName(forecast.model || '-')); const expectedText = Number.isFinite(expected) ? `${signedNum(expected, 3)}%` : '-'; const probabilityText = Number.isFinite(probability) ? `P${num(probability * 100, 1)}%` : 'P-'; const skillText = Number.isFinite(skill) ? `S${num(skill, 3)}` : 'S-'; @@ -817,10 +934,8 @@ HTML = r""" ['Модельный горизонт', `${config.time_series_forecast_horizon} свечи`], ['Walk-forward окно', `${config.time_series_validation_window} свечей`], ['Мин. edge прогноза', `${num(config.time_series_min_edge_percent, 3)}%`], - ['LSTM-кандидат', yesNo(config.time_series_lstm_enabled)], - ['LSTM окно / юниты', `${config.time_series_lstm_lookback} / ${config.time_series_lstm_units}`], - ['LSTM ridge', `${num(config.time_series_lstm_ridge, 5)}`], - ['LSTM файл', config.time_series_lstm_model_path || '-'], + ['Нейропрогноз', modelArtifactSummary(config)], + ['Файл модели', config.time_series_lstm_model_path || '-'], ['Лимит в позициях', money(config.max_total_exposure_usdt)], ['Лимит позиций', `${config.max_open_positions} всего / ${config.max_positions_per_symbol} на пару`], ['Стоп / цель', `${num(config.stop_loss_percent * 100, 2)}% / ${num(config.take_profit_percent * 100, 2)}%`], @@ -831,7 +946,7 @@ HTML = r""" ['Поток данных', yesNo(config.websocket_enabled)] ]; document.getElementById('configGrid').innerHTML = keys.map(([k, v]) => ` -
${k}${v}
+
${escapeHtml(k)}${escapeHtml(String(v))}
`).join(''); } function renderFastMode(config) { diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 3cdbde8..a30e346 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -1,6 +1,9 @@ from __future__ import annotations +import json + from crypto_spot_bot.dashboard import _apply_fast_trading +from crypto_spot_bot.dashboard import _safe_config from crypto_spot_bot.storage import Storage @@ -15,3 +18,32 @@ def test_apply_fast_trading_updates_runtime_and_env(make_settings, tmp_path) -> assert settings.fast_trading_enabled is True assert storage.get_runtime("fast_trading_enabled") is True assert "FAST_TRADING_ENABLED=true" in settings.env_file_path.read_text(encoding="utf-8") + + +def test_safe_config_summarizes_torch_forecast_artifact(make_settings, tmp_path) -> None: + artifact_path = tmp_path / "lstm_forecaster.json" + artifact_path.write_text( + json.dumps( + { + "type": "pytorch_recurrent_forecaster", + "created_at": "2026-06-20T18:15:05+00:00", + "symbols": { + "BTCUSDT": {"model": "torch_lstm"}, + "ETHUSDT": {"model": "torch_gru"}, + }, + } + ), + encoding="utf-8", + ) + settings = make_settings(tmp_path, time_series_lstm_model_path=artifact_path) + + config = _safe_config(settings) + + assert config["time_series_model_artifact"] == { + "available": True, + "type": "pytorch_recurrent_forecaster", + "label": "PyTorch LSTM/GRU", + "created_at": "2026-06-20T18:15:05+00:00", + "symbol_count": 2, + "models": ["PyTorch GRU", "PyTorch LSTM"], + }