Clarify forecast model labels in dashboard
This commit is contained in:
@@ -221,6 +221,7 @@ def _safe_config(settings: Settings) -> dict[str, Any]:
|
|||||||
"time_series_lstm_units": settings.time_series_lstm_units,
|
"time_series_lstm_units": settings.time_series_lstm_units,
|
||||||
"time_series_lstm_ridge": settings.time_series_lstm_ridge,
|
"time_series_lstm_ridge": settings.time_series_lstm_ridge,
|
||||||
"time_series_lstm_model_path": str(settings.time_series_lstm_model_path),
|
"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,
|
"stop_loss_percent": settings.stop_loss_percent,
|
||||||
"take_profit_percent": settings.take_profit_percent,
|
"take_profit_percent": settings.take_profit_percent,
|
||||||
"trailing_stop_percent": settings.trailing_stop_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"""
|
HTML = r"""
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="ru">
|
<html lang="ru">
|
||||||
@@ -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) {
|
function forecastHtml(forecast) {
|
||||||
if (!forecast || !forecast.usable) {
|
if (!forecast || !forecast.usable) {
|
||||||
return `<div class="forecast-line"><div class="forecast-chip"><b>Прогноз</b>${escapeHtml(forecast?.reason || 'нет данных')}</div></div>`;
|
return `<div class="forecast-line"><div class="forecast-chip"><b>Прогноз</b>${escapeHtml(modelReason(forecast?.reason || 'нет данных'))}</div></div>`;
|
||||||
}
|
}
|
||||||
return `<div class="forecast-line">
|
return `<div class="forecast-line">
|
||||||
<div class="forecast-chip"><b>Модель</b>${escapeHtml(forecast.model || '-')}</div>
|
<div class="forecast-chip"><b>Модель</b>${escapeHtml(modelName(forecast.model || '-'))}</div>
|
||||||
<div class="forecast-chip"><b>P роста</b>${num((forecast.probability_up || 0) * 100, 1)}%</div>
|
<div class="forecast-chip"><b>P роста</b>${num((forecast.probability_up || 0) * 100, 1)}%</div>
|
||||||
<div class="forecast-chip"><b>Ожидание</b><span class="${signedClass(forecast.expected_return_percent || 0)}">${signedNum(forecast.expected_return_percent, 3)}%</span></div>
|
<div class="forecast-chip"><b>Ожидание</b><span class="${signedClass(forecast.expected_return_percent || 0)}">${signedNum(forecast.expected_return_percent, 3)}%</span></div>
|
||||||
<div class="forecast-chip"><b>Волат.</b>${num(forecast.volatility_percent, 3)}%</div>
|
<div class="forecast-chip"><b>Волат.</b>${num(forecast.volatility_percent, 3)}%</div>
|
||||||
@@ -781,7 +898,7 @@ HTML = r"""
|
|||||||
const expected = Number(forecast.expected_return_percent);
|
const expected = Number(forecast.expected_return_percent);
|
||||||
const probability = Number(forecast.probability_up);
|
const probability = Number(forecast.probability_up);
|
||||||
const skill = Number(forecast.skill);
|
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 expectedText = Number.isFinite(expected) ? `${signedNum(expected, 3)}%` : '-';
|
||||||
const probabilityText = Number.isFinite(probability) ? `P${num(probability * 100, 1)}%` : 'P-';
|
const probabilityText = Number.isFinite(probability) ? `P${num(probability * 100, 1)}%` : 'P-';
|
||||||
const skillText = Number.isFinite(skill) ? `S${num(skill, 3)}` : 'S-';
|
const skillText = Number.isFinite(skill) ? `S${num(skill, 3)}` : 'S-';
|
||||||
@@ -817,10 +934,8 @@ HTML = r"""
|
|||||||
['Модельный горизонт', `${config.time_series_forecast_horizon} свечи`],
|
['Модельный горизонт', `${config.time_series_forecast_horizon} свечи`],
|
||||||
['Walk-forward окно', `${config.time_series_validation_window} свечей`],
|
['Walk-forward окно', `${config.time_series_validation_window} свечей`],
|
||||||
['Мин. edge прогноза', `${num(config.time_series_min_edge_percent, 3)}%`],
|
['Мин. edge прогноза', `${num(config.time_series_min_edge_percent, 3)}%`],
|
||||||
['LSTM-кандидат', yesNo(config.time_series_lstm_enabled)],
|
['Нейропрогноз', modelArtifactSummary(config)],
|
||||||
['LSTM окно / юниты', `${config.time_series_lstm_lookback} / ${config.time_series_lstm_units}`],
|
['Файл модели', config.time_series_lstm_model_path || '-'],
|
||||||
['LSTM ridge', `${num(config.time_series_lstm_ridge, 5)}`],
|
|
||||||
['LSTM файл', config.time_series_lstm_model_path || '-'],
|
|
||||||
['Лимит в позициях', money(config.max_total_exposure_usdt)],
|
['Лимит в позициях', money(config.max_total_exposure_usdt)],
|
||||||
['Лимит позиций', `${config.max_open_positions} всего / ${config.max_positions_per_symbol} на пару`],
|
['Лимит позиций', `${config.max_open_positions} всего / ${config.max_positions_per_symbol} на пару`],
|
||||||
['Стоп / цель', `${num(config.stop_loss_percent * 100, 2)}% / ${num(config.take_profit_percent * 100, 2)}%`],
|
['Стоп / цель', `${num(config.stop_loss_percent * 100, 2)}% / ${num(config.take_profit_percent * 100, 2)}%`],
|
||||||
@@ -831,7 +946,7 @@ HTML = r"""
|
|||||||
['Поток данных', yesNo(config.websocket_enabled)]
|
['Поток данных', yesNo(config.websocket_enabled)]
|
||||||
];
|
];
|
||||||
document.getElementById('configGrid').innerHTML = keys.map(([k, v]) => `
|
document.getElementById('configGrid').innerHTML = keys.map(([k, v]) => `
|
||||||
<div class="config-item"><span class="muted">${k}</span><strong>${v}</strong></div>
|
<div class="config-item"><span class="muted">${escapeHtml(k)}</span><strong>${escapeHtml(String(v))}</strong></div>
|
||||||
`).join('');
|
`).join('');
|
||||||
}
|
}
|
||||||
function renderFastMode(config) {
|
function renderFastMode(config) {
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
from crypto_spot_bot.dashboard import _apply_fast_trading
|
from crypto_spot_bot.dashboard import _apply_fast_trading
|
||||||
|
from crypto_spot_bot.dashboard import _safe_config
|
||||||
from crypto_spot_bot.storage import Storage
|
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 settings.fast_trading_enabled is True
|
||||||
assert storage.get_runtime("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")
|
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"],
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user