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"]