diff --git a/crypto_spot_bot/dashboard.py b/crypto_spot_bot/dashboard.py
index 2651d34..f598d06 100644
--- a/crypto_spot_bot/dashboard.py
+++ b/crypto_spot_bot/dashboard.py
@@ -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"""
${num(pattern.score, 2)}
${forecastHtml(market.forecast || {})}
+ ${featureSnapshotHtml(market.forecast || {})}
RSI14${num(last.rsi_14, 2)}
@@ -847,6 +904,43 @@ HTML = r"""
`;
}
+ function featureSnapshotHtml(forecast) {
+ const rows = Array.isArray(forecast?.feature_snapshot) ? forecast.feature_snapshot : [];
+ if (!rows.length) return '';
+ const body = rows.map(feature => `
+
+ |
+ ${escapeHtml(feature.label || feature.name || '-')}
+ ${escapeHtml(feature.name || '')}
+ |
+ ${escapeHtml(feature.group || '-')} |
+ ${escapeHtml(feature.raw_display ?? valueText(feature.raw_value))} |
+ ${escapeHtml(feature.model_display ?? valueText(feature.model_value))} |
+ ${escapeHtml(feature.meaning || '')} |
+ ${escapeHtml(feature.interpretation || '')} |
+
+ `).join('');
+ return `
+ Входы нейросети (${rows.length})
+ Текущее значение берется с последней закрытой свечи. Значение в модели - это нормализованный вход после обучающих mean/scale; именно его получает LSTM/GRU.
+
+
+
+
+ | Параметр |
+ Группа |
+ Сейчас |
+ В модели |
+ Что это |
+ Что значит |
+
+
+ ${body}
+
+
+ `;
+ }
+
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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[ch]));
}
diff --git a/crypto_spot_bot/time_series.py b/crypto_spot_bot/time_series.py
index 687944b..cc05e06 100644
--- a/crypto_spot_bot/time_series.py
+++ b/crypto_spot_bot/time_series.py
@@ -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]],
*,
diff --git a/tests/test_time_series.py b/tests/test_time_series.py
index 876afce..1c95de8 100644
--- a/tests/test_time_series.py
+++ b/tests/test_time_series.py
@@ -322,3 +322,7 @@ def test_time_series_forecaster_reads_probabilistic_multi_horizon_artifact(make_
assert forecast.probability_up > 0.85
assert forecast.quantile_10_percent <= forecast.quantile_50_percent <= forecast.quantile_90_percent
assert sorted(forecast.horizon_forecasts) == ["1", "3"]
+ assert [item["name"] for item in forecast.feature_snapshot] == ["return_1", "range_percent"]
+ assert forecast.feature_snapshot[0]["label"] == "Доходность 1ч"
+ assert forecast.feature_snapshot[0]["raw_display"].endswith("%")
+ assert "диапазон" in forecast.feature_snapshot[0]["interpretation"]