Explain Torch forecast inputs in dashboard

This commit is contained in:
Codex
2026-06-23 07:44:15 +03:00
parent a548c0e890
commit 12f470e0a3
3 changed files with 317 additions and 0 deletions
+98
View File
@@ -511,6 +511,62 @@ HTML = r"""
background: #ffffff;
}
.forecast-chip b { display: block; color: var(--muted); font-size: 10px; margin-bottom: 2px; }
.feature-panel {
margin: 0 0 9px;
border: 1px solid var(--border);
border-radius: 7px;
background: #fff;
}
.feature-panel summary {
cursor: pointer;
padding: 8px 9px;
font-size: 12px;
font-weight: 850;
color: var(--accent);
list-style-position: inside;
}
.feature-note {
padding: 0 9px 8px;
color: var(--muted);
font-size: 11px;
line-height: 1.35;
}
.feature-table-wrap {
max-height: 360px;
overflow: auto;
border-top: 1px solid var(--border);
}
.feature-table {
font-size: 12px;
min-width: 720px;
}
.feature-table th,
.feature-table td {
white-space: normal;
vertical-align: top;
padding: 7px 8px;
}
.feature-table .param-name {
font-weight: 850;
color: var(--text);
}
.feature-table .technical-name {
margin-top: 2px;
color: var(--muted);
font-size: 10px;
word-break: break-word;
}
.feature-group {
display: inline-flex;
align-items: center;
min-height: 20px;
padding: 2px 6px;
border-radius: 5px;
background: var(--panel-2);
color: var(--muted);
font-size: 10px;
font-weight: 800;
}
.layout { grid-template-columns: 1.2fr 0.8fr; align-items: start; }
.panel { padding: 14px; min-width: 0; }
.table-wrap { overflow: auto; max-height: 330px; border: 1px solid var(--border); border-radius: 7px; }
@@ -767,6 +823,7 @@ HTML = r"""
<span>${num(pattern.score, 2)}</span>
</div>
${forecastHtml(market.forecast || {})}
${featureSnapshotHtml(market.forecast || {})}
<canvas width="520" height="220"></canvas>
<div class="indicators">
<div class="indicator"><b>RSI14</b>${num(last.rsi_14, 2)}</div>
@@ -847,6 +904,43 @@ HTML = r"""
</div>`;
}
function featureSnapshotHtml(forecast) {
const rows = Array.isArray(forecast?.feature_snapshot) ? forecast.feature_snapshot : [];
if (!rows.length) return '';
const body = rows.map(feature => `
<tr>
<td>
<div class="param-name">${escapeHtml(feature.label || feature.name || '-')}</div>
<div class="technical-name">${escapeHtml(feature.name || '')}</div>
</td>
<td><span class="feature-group">${escapeHtml(feature.group || '-')}</span></td>
<td>${escapeHtml(feature.raw_display ?? valueText(feature.raw_value))}</td>
<td>${escapeHtml(feature.model_display ?? valueText(feature.model_value))}</td>
<td>${escapeHtml(feature.meaning || '')}</td>
<td>${escapeHtml(feature.interpretation || '')}</td>
</tr>
`).join('');
return `<details class="feature-panel">
<summary>Входы нейросети (${rows.length})</summary>
<div class="feature-note">Текущее значение берется с последней закрытой свечи. Значение в модели - это нормализованный вход после обучающих mean/scale; именно его получает LSTM/GRU.</div>
<div class="feature-table-wrap">
<table class="feature-table">
<thead>
<tr>
<th>Параметр</th>
<th>Группа</th>
<th>Сейчас</th>
<th>В модели</th>
<th>Что это</th>
<th>Что значит</th>
</tr>
</thead>
<tbody>${body}</tbody>
</table>
</div>
</details>`;
}
function drawCandles(canvas, candles) {
const ctx = canvas.getContext('2d');
const w = canvas.width, h = canvas.height;
@@ -1117,6 +1211,10 @@ HTML = r"""
const sign = parsed > 0 ? '+' : '';
return `${sign}${parsed.toFixed(digits)}`;
}
function valueText(value, digits = 4) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed.toFixed(digits) : '-';
}
function escapeHtml(value) {
return String(value).replace(/[&<>"']/g, ch => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#039;'}[ch]));
}
+215
View File
@@ -69,6 +69,65 @@ DEFAULT_TORCH_FEATURES = (
)
FEATURE_DESCRIPTIONS: dict[str, tuple[str, str, str]] = {
"return_1": ("Цена", "Доходность 1ч", "Изменение цены закрытия за последнюю 1h свечу."),
"return_3": ("Цена", "Доходность 3ч", "Изменение цены закрытия за последние 3 часовые свечи."),
"return_6": ("Цена", "Доходность 6ч", "Изменение цены закрытия за последние 6 часовых свечей."),
"return_12": ("Цена", "Доходность 12ч", "Изменение цены закрытия за последние 12 часовых свечей."),
"return_24": ("Цена", "Доходность 24ч", "Изменение цены закрытия за последние 24 часовые свечи."),
"range_percent": ("Свеча", "Диапазон свечи", "Размер high-low последней свечи относительно цены закрытия."),
"body_percent": ("Свеча", "Тело свечи", "Разница close-open относительно цены закрытия; знак показывает цвет свечи."),
"upper_wick_percent": ("Свеча", "Верхняя тень", "Насколько далеко цена уходила выше тела свечи."),
"lower_wick_percent": ("Свеча", "Нижняя тень", "Насколько далеко цена уходила ниже тела свечи."),
"volume_change": ("Объем", "Изменение объема", "Изменение объема последней свечи относительно предыдущей."),
"volume_ratio": ("Объем", "Объем к MA20", "Отклонение текущего объема от средней за 20 свечей."),
"volume_percentile_20": ("Объем", "Процентиль объема", "Позиция текущего объема среди последних 20 свечей."),
"atr_percent": ("Волатильность", "ATR14 %", "Средний торговый диапазон ATR14 относительно цены."),
"atr_ratio_20": ("Волатильность", "ATR к среднему", "Отклонение текущего ATR от среднего ATR за 20 свечей."),
"realized_volatility_12": ("Волатильность", "Реализованная вола 12ч", "Фактическая волатильность доходностей за 12 свечей."),
"realized_volatility_24": ("Волатильность", "Реализованная вола 24ч", "Фактическая волатильность доходностей за 24 свечи."),
"rsi_centered": ("Индикаторы", "RSI14 от 50", "RSI14, приведенный к центру 50: выше нуля сильнее покупатели."),
"rsi_slope_6": ("Индикаторы", "Наклон RSI 6ч", "Изменение RSI14 за последние 6 свечей."),
"macd_hist_percent": ("Индикаторы", "MACD histogram", "MACD histogram относительно цены; знак показывает импульс."),
"macd_hist_slope_3": ("Индикаторы", "Наклон MACD hist", "Изменение MACD histogram за последние 3 свечи."),
"ema50_gap_percent": ("EMA/тренд", "Цена к EMA50", "Расстояние цены закрытия до EMA50."),
"ema200_gap_percent": ("EMA/тренд", "Цена к EMA200", "Расстояние цены закрытия до EMA200."),
"ema20_slope_6": ("EMA/тренд", "Наклон EMA20", "Изменение EMA20 за последние 6 свечей."),
"ema50_slope_12": ("EMA/тренд", "Наклон EMA50", "Изменение EMA50 за последние 12 свечей."),
"ema200_slope_24": ("EMA/тренд", "Наклон EMA200", "Изменение EMA200 за последние 24 свечи."),
"ema50_ema200_gap_percent": ("EMA/тренд", "EMA50 к EMA200", "Расстояние EMA50 относительно EMA200."),
"range_position_50": ("Цена", "Позиция в диапазоне 50ч", "Где текущая цена внутри high-low диапазона последних 50 свечей."),
"trend_return_4h": ("Цена", "Тренд 4ч", "Изменение цены за последние 4 свечи."),
"trend_return_24h": ("Цена", "Тренд 24ч", "Изменение цены за последние 24 свечи."),
"daily_close_ema200_gap_percent": ("Дневной тренд", "D цена к EMA200", "Расстояние дневного close до дневной EMA200."),
"daily_ema50_ema200_gap_percent": ("Дневной тренд", "D EMA50 к EMA200", "Расстояние дневной EMA50 относительно дневной EMA200."),
"daily_ema50_slope": ("Дневной тренд", "D наклон EMA50", "Изменение дневной EMA50 за последние несколько дневных свечей."),
"btc_return_1": ("BTC/ETH контекст", "BTC 1ч", "Изменение BTCUSDT за последнюю 1h свечу."),
"btc_return_3": ("BTC/ETH контекст", "BTC 3ч", "Изменение BTCUSDT за последние 3 часа."),
"btc_return_6": ("BTC/ETH контекст", "BTC 6ч", "Изменение BTCUSDT за последние 6 часов."),
"btc_return_24": ("BTC/ETH контекст", "BTC 24ч", "Изменение BTCUSDT за последние 24 часа."),
"eth_return_1": ("BTC/ETH контекст", "ETH 1ч", "Изменение ETHUSDT за последнюю 1h свечу."),
"eth_return_3": ("BTC/ETH контекст", "ETH 3ч", "Изменение ETHUSDT за последние 3 часа."),
"eth_return_6": ("BTC/ETH контекст", "ETH 6ч", "Изменение ETHUSDT за последние 6 часов."),
"eth_return_24": ("BTC/ETH контекст", "ETH 24ч", "Изменение ETHUSDT за последние 24 часа."),
"relative_btc_return_3": ("BTC/ETH контекст", "Сила к BTC 3ч", "Доходность пары за 3 часа минус доходность BTC за 3 часа."),
"relative_eth_return_3": ("BTC/ETH контекст", "Сила к ETH 3ч", "Доходность пары за 3 часа минус доходность ETH за 3 часа."),
"btc_eth_return_spread_3": ("BTC/ETH контекст", "BTC-ETH 3ч", "Разница 3-часовой доходности BTC и ETH."),
"pattern_score": ("Шаблон", "Оценка шаблона", "Числовая оценка текущего рыночного шаблона от 0 до 1."),
"pattern_bullish": ("Шаблон", "Бычий шаблон", "Флаг, что текущий шаблон похож на бычий."),
"pattern_bearish": ("Шаблон", "Медвежий шаблон", "Флаг, что текущий шаблон похож на медвежий."),
"pattern_range": ("Шаблон", "Флэтовый шаблон", "Флаг, что рынок похож на диапазон/флэт."),
"pattern_pullback": ("Шаблон", "Откат", "Флаг отката внутри восходящего движения."),
"pattern_oversold_reversal": ("Шаблон", "Перепроданный разворот", "Флаг возможного разворота после перепроданности."),
"pattern_stabilized_drop": ("Шаблон", "Падение стабилизировалось", "Флаг замедления падения и попытки стабилизации."),
"pattern_breakout": ("Шаблон", "Пробой вверх", "Флаг пробоя верхней части диапазона с объемом."),
"pattern_breakdown": ("Шаблон", "Пробой вниз", "Флаг пробоя нижней части диапазона с объемом."),
"pattern_fast_drop": ("Шаблон", "Быстрое падение", "Флаг резкого снижения или сильной перепроданности."),
"pattern_volume_spike": ("Шаблон", "Всплеск объема", "Флаг объема заметно выше обычного."),
"pattern_range_position_20": ("Шаблон", "Позиция в диапазоне 20ч", "Где цена внутри high-low диапазона последних 20 свечей."),
}
@dataclass(slots=True)
class TimeSeriesForecast:
enabled: bool
@@ -92,6 +151,7 @@ class TimeSeriesForecast:
quantile_90_percent: float
conservative_return_percent: float
target_transform: str
feature_snapshot: list[dict[str, Any]] = field(default_factory=list)
horizon_forecasts: dict[str, Any] = field(default_factory=dict)
candidates: list[dict[str, Any]] = field(default_factory=list)
@@ -126,6 +186,7 @@ class TimeSeriesForecaster:
artifact = self._load_lstm_artifact()
entry = _torch_recurrent_entry(symbol, artifact)
model = _torch_recurrent_model_name(symbol, artifact)
clip = _clamp(_float_entry(entry or {}, "clip", 8.0), 1.0, 50.0)
feature_rows = (
_feature_matrix(
candles,
@@ -137,6 +198,7 @@ class TimeSeriesForecaster:
if entry
else []
)
feature_snapshot = _feature_snapshot(feature_rows, entry, clip)
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")
@@ -215,6 +277,7 @@ class TimeSeriesForecaster:
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")),
feature_snapshot=feature_snapshot,
horizon_forecasts=_public_horizon_forecasts(prediction),
candidates=[{"model": model, "mae_percent": round(model_mae * 100, 4)}],
)
@@ -274,6 +337,7 @@ class TimeSeriesForecaster:
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")),
feature_snapshot=feature_snapshot,
horizon_forecasts={},
candidates=[{"model": model, "mae_percent": round(model_mae * 100, 4)}],
)
@@ -322,6 +386,7 @@ def _empty_forecast(enabled: bool, reason: str) -> TimeSeriesForecast:
quantile_90_percent=0.0,
conservative_return_percent=0.0,
target_transform="none",
feature_snapshot=[],
horizon_forecasts={},
)
@@ -1033,6 +1098,156 @@ def _normalize_feature_rows(rows: list[list[float]], entry: dict[str, Any], clip
return normalized
def _feature_snapshot(
feature_rows: list[list[float]],
entry: dict[str, Any] | None,
clip: float,
) -> list[dict[str, Any]]:
if not entry or not feature_rows:
return []
names = _feature_names(entry)
latest = feature_rows[-1]
normalized_rows = _normalize_feature_rows([latest], entry, clip)
normalized = normalized_rows[-1] if normalized_rows else []
means = _float_vector(entry.get("feature_means"))
scales = _float_vector(entry.get("feature_scales"))
snapshot: list[dict[str, Any]] = []
for index, name in enumerate(names):
raw_value = float(latest[index]) if index < len(latest) else 0.0
model_value = float(normalized[index]) if index < len(normalized) else 0.0
group, label, meaning = FEATURE_DESCRIPTIONS.get(
name,
("Прочее", name, "Технический входной признак модели."),
)
snapshot.append(
{
"name": name,
"label": label,
"group": group,
"raw_value": round(raw_value, 10),
"raw_display": _feature_raw_display(name, raw_value),
"model_value": round(model_value, 4),
"model_display": f"{model_value:+.2f}",
"mean": round(float(means[index]), 10) if index < len(means) else 0.0,
"scale": round(float(scales[index]), 10) if index < len(scales) else 1.0,
"meaning": meaning,
"interpretation": _feature_interpretation(name, raw_value, model_value),
}
)
return snapshot
def _feature_raw_display(name: str, value: float) -> str:
if _feature_is_log_percent(name):
return f"{(math.exp(value) - 1) * 100:+.3f}%"
if _feature_is_linear_percent(name):
return f"{value * 100:+.3f}%"
if name in {"rsi_centered"}:
return f"RSI {value * 50 + 50:.1f}"
if name in {"rsi_slope_6"}:
return f"{value * 50:+.2f} RSI"
if name in {"volume_percentile_20", "range_position_50", "pattern_range_position_20"}:
return f"{value * 100:.1f}%"
if name.startswith("pattern_") and name != "pattern_score":
return "да" if value >= 0.5 else "нет"
if name == "pattern_score":
return f"{value:.2f}"
return f"{value:+.4f}"
def _feature_interpretation(name: str, value: float, model_value: float) -> str:
norm = _model_value_text(model_value)
if name.startswith("pattern_") and name != "pattern_score" and name != "pattern_range_position_20":
state = "шаблон активен" if value >= 0.5 else "шаблон не активен"
return f"{state}; {norm}."
if name in {"volume_percentile_20", "range_position_50", "pattern_range_position_20"}:
if value >= 0.8:
state = "значение находится в верхней части диапазона"
elif value <= 0.2:
state = "значение находится в нижней части диапазона"
else:
state = "значение около середины диапазона"
return f"{state}; {norm}."
if name in {"volume_ratio", "atr_ratio_20"}:
state = "выше среднего" if value > 0 else "ниже среднего" if value < 0 else "около среднего"
return f"{state}; {norm}."
if name == "rsi_centered":
rsi = value * 50 + 50
if rsi >= 65:
state = "RSI высокий"
elif rsi <= 35:
state = "RSI низкий"
else:
state = "RSI в средней зоне"
return f"{state}; {norm}."
if _feature_is_log_percent(name) or _feature_is_linear_percent(name) or name.endswith("_slope"):
if value > 0:
state = "положительное значение"
elif value < 0:
state = "отрицательное значение"
else:
state = "нейтральное значение"
return f"{state}; {norm}."
if name == "pattern_score":
if value >= 0.65:
state = "шаблон скорее поддерживает long"
elif value <= 0.35:
state = "шаблон скорее против long"
else:
state = "шаблон нейтральный"
return f"{state}; {norm}."
return norm + "."
def _model_value_text(value: float) -> str:
magnitude = abs(value)
if magnitude >= 2.0:
return "для модели это сильное отклонение от обучающей нормы"
if magnitude >= 1.0:
return "для модели это заметное отклонение от обучающей нормы"
return "для модели это близко к обычному диапазону"
def _feature_is_log_percent(name: str) -> bool:
return (
name.startswith("return_")
or name.startswith("btc_return_")
or name.startswith("eth_return_")
or name.startswith("relative_")
or name in {
"volume_change",
"trend_return_4h",
"trend_return_24h",
"ema20_slope_6",
"ema50_slope_12",
"ema200_slope_24",
"daily_ema50_slope",
"btc_eth_return_spread_3",
}
)
def _feature_is_linear_percent(name: str) -> bool:
return name in {
"range_percent",
"body_percent",
"upper_wick_percent",
"lower_wick_percent",
"volume_ratio",
"atr_percent",
"atr_ratio_20",
"realized_volatility_12",
"realized_volatility_24",
"macd_hist_percent",
"macd_hist_slope_3",
"ema50_gap_percent",
"ema200_gap_percent",
"ema50_ema200_gap_percent",
"daily_close_ema200_gap_percent",
"daily_ema50_ema200_gap_percent",
}
def _torch_recurrent_hidden(
sequence: list[list[float]],
*,