Files
TradeBot/crypto_spot_bot/dashboard.py
T
2026-06-23 07:44:15 +03:00

1247 lines
57 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import json
from contextlib import asynccontextmanager
from typing import Any
from fastapi import FastAPI, Response
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse
from crypto_spot_bot.bot import CryptoSpotBot
from crypto_spot_bot.bybit import BybitClient
from crypto_spot_bot.config import Settings, load_settings, update_env_value
from crypto_spot_bot.execution import LiveBroker, PaperBroker
from crypto_spot_bot.learning import TradeLearner
from crypto_spot_bot.market_data import MarketData
from crypto_spot_bot.patterns import PatternAnalyzer
from crypto_spot_bot.storage import Storage
from crypto_spot_bot.strategy import SpotStrategy
from crypto_spot_bot.time_series import TimeSeriesForecaster
def create_app(settings: Settings | None = None) -> FastAPI:
settings = settings or load_settings()
storage = Storage(settings.database_path)
runtime_fast_trading = storage.get_runtime("fast_trading_enabled", None)
if isinstance(runtime_fast_trading, bool):
settings.fast_trading_enabled = runtime_fast_trading
client = BybitClient(settings)
market = MarketData(settings, client, storage)
broker: PaperBroker | LiveBroker
if settings.trading_mode == "live":
broker = LiveBroker(settings, storage, client)
else:
broker = PaperBroker(settings, storage)
strategy = SpotStrategy(settings)
pattern_analyzer = PatternAnalyzer()
learner = TradeLearner(settings, storage)
forecaster = TimeSeriesForecaster(settings)
bot = CryptoSpotBot(settings, storage, market, broker, strategy, pattern_analyzer, learner, forecaster)
@asynccontextmanager
async def lifespan(_: FastAPI):
await bot.start()
try:
yield
finally:
await bot.stop()
app = FastAPI(title="Крипто спот-бот", lifespan=lifespan)
app.state.settings = settings
app.state.storage = storage
app.state.bot = bot
app.state.market = market
@app.get("/", response_class=HTMLResponse)
async def index() -> str:
return HTML
@app.get("/api/health")
async def health() -> dict[str, Any]:
return {"ok": True, "running": bot.running, "mode": settings.trading_mode}
@app.get("/api/status")
async def status() -> dict[str, Any]:
return {
"status": bot.status().as_dict(),
"account": bot.account_snapshot(),
"positions": bot.positions_snapshot(),
"learning": bot.learning_snapshot(),
"latest_equity": storage.latest_equity(),
}
@app.get("/api/markets")
async def markets() -> dict[str, Any]:
return market.snapshot()
@app.get("/api/trades")
async def trades(limit: int = 80) -> dict[str, Any]:
return {"items": storage.recent_trades(_limit(limit))}
@app.get("/api/signals")
async def signals(limit: int = 120) -> dict[str, Any]:
return {"items": storage.recent_signals(_limit(limit))}
@app.get("/api/events")
async def events(limit: int = 120) -> dict[str, Any]:
return {"items": storage.recent_events(_limit(limit))}
@app.get("/api/config")
async def config() -> dict[str, Any]:
return _safe_config(settings)
@app.post("/api/config/fast-trading")
async def set_fast_trading(payload: dict[str, Any]) -> dict[str, Any]:
enabled = _enabled_from_payload(payload)
env_persisted = _apply_fast_trading(settings, storage, enabled)
response = _safe_config(settings)
response["env_persisted"] = env_persisted
return response
@app.post("/api/control/start")
async def start() -> dict[str, Any]:
await bot.start()
return bot.status().as_dict()
@app.post("/api/control/stop")
async def stop() -> dict[str, Any]:
await bot.stop()
return bot.status().as_dict()
@app.get("/metrics")
async def metrics() -> Response:
account = bot.account_snapshot()
lines = [
"# HELP tradebot_equity_usdt Current account equity.",
"# TYPE tradebot_equity_usdt gauge",
f"tradebot_equity_usdt {account['equity']:.8f}",
"# HELP tradebot_cash_usdt Current free USDT cash.",
"# TYPE tradebot_cash_usdt gauge",
f"tradebot_cash_usdt {account['cash']:.8f}",
"# HELP tradebot_open_positions Open positions count.",
"# TYPE tradebot_open_positions gauge",
f"tradebot_open_positions {len(bot.positions_snapshot())}",
"# HELP tradebot_websocket_connected Bybit WebSocket connection status.",
"# TYPE tradebot_websocket_connected gauge",
f"tradebot_websocket_connected {1 if market.ws_connected else 0}",
"# HELP tradebot_fast_trading_enabled Fast trading mode status.",
"# TYPE tradebot_fast_trading_enabled gauge",
f"tradebot_fast_trading_enabled {1 if settings.fast_trading_enabled else 0}",
"# HELP tradebot_loop_interval_seconds Effective bot decision loop interval.",
"# TYPE tradebot_loop_interval_seconds gauge",
f"tradebot_loop_interval_seconds {settings.effective_loop_interval_seconds:.4f}",
]
return PlainTextResponse("\n".join(lines) + "\n")
@app.exception_handler(Exception)
async def error_handler(_, exc: Exception) -> JSONResponse:
storage.event(f"API error: {exc}", "ERROR")
return JSONResponse({"error": str(exc)}, status_code=500)
return app
def _limit(value: int) -> int:
return max(1, min(int(value), 500))
def _enabled_from_payload(payload: dict[str, Any]) -> bool:
value = payload.get("enabled")
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.strip().lower() in {"1", "true", "yes", "y", "on", "вкл", "включено"}
return bool(value)
def _apply_fast_trading(settings: Settings, storage: Storage, enabled: bool) -> bool:
settings.fast_trading_enabled = enabled
storage.set_runtime("fast_trading_enabled", enabled)
env_persisted = True
try:
update_env_value(settings.env_file_path, "FAST_TRADING_ENABLED", "true" if enabled else "false")
except OSError as exc:
env_persisted = False
storage.event(f"Быстрая торговля изменена только в runtime, .env не записан: {exc}", "WARN")
state = "включена" if enabled else "выключена"
storage.event(f"Быстрая торговля {state}")
return env_persisted
def _safe_config(settings: Settings) -> dict[str, Any]:
return {
"trading_mode": settings.trading_mode,
"bybit_testnet": settings.bybit_testnet,
"starting_balance_usdt": settings.starting_balance_usdt,
"auto_select_symbols": settings.auto_select_symbols,
"top_symbols_count": settings.top_symbols_count,
"symbols": settings.symbols,
"strategy_mode": settings.strategy_mode,
"base_interval": settings.base_interval,
"kline_limit": settings.kline_limit,
"trend_interval": settings.trend_interval,
"trend_kline_limit": settings.trend_kline_limit,
"loop_interval_seconds": settings.loop_interval_seconds,
"fast_trading_enabled": settings.fast_trading_enabled,
"fast_loop_interval_seconds": settings.fast_loop_interval_seconds,
"effective_loop_interval_seconds": settings.effective_loop_interval_seconds,
"fast_entry_cooldown_seconds": settings.fast_entry_cooldown_seconds,
"effective_entry_cooldown_seconds": settings.effective_entry_cooldown_seconds,
"max_entries_per_minute": settings.max_entries_per_minute,
"websocket_enabled": settings.websocket_enabled,
"min_signal_confidence": settings.min_signal_confidence,
"max_spread_percent": settings.max_spread_percent,
"min_24h_turnover_usdt": settings.min_24h_turnover_usdt,
"pattern_analysis_enabled": settings.pattern_analysis_enabled,
"pattern_score_weight": settings.pattern_score_weight,
"learning_enabled": settings.learning_enabled,
"learning_lookback_trades": settings.learning_lookback_trades,
"learning_min_samples": settings.learning_min_samples,
"learning_max_adjustment": settings.learning_max_adjustment,
"learning_max_position_multiplier": settings.learning_max_position_multiplier,
"min_position_usdt": settings.min_position_usdt,
"max_position_usdt": settings.max_position_usdt,
"max_symbol_exposure_usdt": settings.max_symbol_exposure_usdt,
"max_total_exposure_usdt": settings.max_total_exposure_usdt,
"max_open_positions": settings.max_open_positions,
"max_positions_per_symbol": settings.max_positions_per_symbol,
"grid_trading_enabled": settings.grid_trading_enabled,
"grid_entry_confidence": settings.grid_entry_confidence,
"grid_buy_zone": settings.grid_buy_zone,
"grid_max_position_usdt": settings.grid_max_position_usdt,
"rebound_trading_enabled": settings.rebound_trading_enabled,
"rebound_entry_confidence": settings.rebound_entry_confidence,
"rebound_min_probability": settings.rebound_min_probability,
"rebound_max_position_usdt": settings.rebound_max_position_usdt,
"kelly_sizing_enabled": settings.kelly_sizing_enabled,
"kelly_fraction": settings.kelly_fraction,
"kelly_max_fraction": settings.kelly_max_fraction,
"risk_per_trade_percent": settings.risk_per_trade_percent,
"atr_trailing_multiplier": settings.atr_trailing_multiplier,
"trend_rsi_min": settings.trend_rsi_min,
"trend_rsi_max": settings.trend_rsi_max,
"time_series_forecast_enabled": settings.time_series_forecast_enabled,
"time_series_min_candles": settings.time_series_min_candles,
"time_series_forecast_horizon": settings.time_series_forecast_horizon,
"time_series_min_edge_percent": settings.time_series_min_edge_percent,
"time_series_max_adjustment": settings.time_series_max_adjustment,
"time_series_lstm_enabled": settings.time_series_lstm_enabled,
"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,
"min_hold_seconds": settings.min_hold_seconds,
"entry_cooldown_seconds": settings.entry_cooldown_seconds,
"max_daily_drawdown_usdt": settings.max_daily_drawdown_usdt,
"min_cash_reserve_usdt": settings.min_cash_reserve_usdt,
"taker_fee_rate": settings.taker_fee_rate,
"slippage_rate": settings.slippage_rate,
"live_ready": settings.live_ready,
"live_order_max_usdt": settings.live_order_max_usdt,
}
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": [],
}
artifact_type = str(data.get("type", "")).strip()
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"))),
torch_artifact=artifact_type == "pytorch_recurrent_forecaster",
)
for row in rows
if isinstance(row, dict)
}
)
if artifact_type != "pytorch_recurrent_forecaster":
return {
"available": False,
"type": artifact_type or "unknown",
"label": "устаревший файл модели не используется",
"created_at": data.get("created_at", ""),
"symbol_count": len(rows),
"models": models,
}
return {
"available": True,
"type": artifact_type,
"label": "PyTorch LSTM/GRU",
"created_at": data.get("created_at", ""),
"symbol_count": len(rows),
"models": models,
"feature_count": _artifact_feature_count(data, rows),
"target_horizon": _artifact_target_horizon(data, rows),
"direct_horizon": _artifact_direct_horizon(data, rows),
}
def _artifact_feature_count(data: dict[str, Any], rows: list[Any]) -> int:
feature_count = data.get("feature_count")
if isinstance(feature_count, int):
return feature_count
counts = [
int(row.get("input_size", 0))
for row in rows
if isinstance(row, dict) and isinstance(row.get("input_size"), int)
]
return max(counts) if counts else 1
def _artifact_target_horizon(data: dict[str, Any], rows: list[Any]) -> int:
horizon = data.get("target_horizon")
if isinstance(horizon, int):
return horizon
horizons = [
int(row.get("target_horizon", 0))
for row in rows
if isinstance(row, dict) and isinstance(row.get("target_horizon"), int)
]
return max(horizons) if horizons else 0
def _artifact_direct_horizon(data: dict[str, Any], rows: list[Any]) -> bool:
if bool(data.get("direct_horizon")):
return True
return any(isinstance(row, dict) and bool(row.get("direct_horizon")) for row in rows)
def _forecast_model_label(model: str, *, torch_artifact: bool = False) -> str:
normalized = model.strip().lower()
if normalized in {"torch_lstm", "lstm"} and torch_artifact:
return "PyTorch LSTM"
if normalized in {"torch_gru", "gru"} and torch_artifact:
return "PyTorch GRU"
if normalized == "lstm":
return "устаревший артефакт"
if normalized == "gru":
return "устаревший артефакт"
return model
HTML = r"""
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Крипто спот-бот</title>
<style>
:root {
--bg: #f5f7fb;
--panel: #ffffff;
--panel-2: #eef3f8;
--text: #111827;
--muted: #627084;
--border: #d9e1ea;
--accent: #0b7a75;
--accent-2: #2563eb;
--danger: #c2410c;
--green: #0f9f6e;
--red: #d64545;
--shadow: 0 10px 28px rgba(16, 24, 40, 0.08);
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: var(--text);
background: var(--bg);
letter-spacing: 0;
}
header {
position: sticky;
top: 0;
z-index: 5;
display: flex;
justify-content: space-between;
align-items: center;
gap: 18px;
padding: 16px 24px;
background: rgba(255,255,255,0.94);
border-bottom: 1px solid var(--border);
backdrop-filter: blur(14px);
}
h1 { margin: 0; font-size: 22px; line-height: 1.15; }
h2 { margin: 0 0 12px; font-size: 17px; }
.subline { margin-top: 4px; color: var(--muted); font-size: 13px; }
.header-actions { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
button {
border: 1px solid var(--border);
background: var(--panel);
color: var(--text);
padding: 9px 13px;
border-radius: 7px;
font-weight: 700;
cursor: pointer;
font-size: 13px;
}
button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
button.danger { background: var(--danger); border-color: var(--danger); color: #fff; }
.switch-control {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 34px;
padding: 4px 8px;
border: 1px solid var(--border);
border-radius: 7px;
background: var(--panel);
color: var(--muted);
font-size: 12px;
font-weight: 800;
cursor: pointer;
user-select: none;
}
.switch-control input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.switch {
position: relative;
width: 42px;
height: 24px;
flex: 0 0 42px;
border-radius: 999px;
background: #cbd5e1;
transition: background 0.15s ease;
}
.switch::after {
content: "";
position: absolute;
top: 3px;
left: 3px;
width: 18px;
height: 18px;
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.25);
transition: transform 0.15s ease;
}
.switch-control input:checked + .switch { background: var(--accent); }
.switch-control input:checked + .switch::after { transform: translateX(18px); }
.switch-control input:focus-visible + .switch { outline: 2px solid var(--accent-2); outline-offset: 2px; }
.switch-control input:disabled + .switch { opacity: 0.65; }
.badge {
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 5px 9px;
border-radius: 7px;
background: var(--panel-2);
color: var(--muted);
font-size: 12px;
font-weight: 800;
}
.badge.ok { color: #075e42; background: #daf5ea; }
.badge.warn { color: #8a3b12; background: #ffedd5; }
main { padding: 18px 24px 30px; }
.grid { display: grid; gap: 14px; }
.stats { grid-template-columns: repeat(6, minmax(130px, 1fr)); margin-bottom: 16px; }
.stat, .panel, .market-card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: var(--shadow);
}
.stat { padding: 13px 14px; min-height: 82px; }
.label { color: var(--muted); font-size: 12px; font-weight: 800; text-transform: uppercase; }
.value { margin-top: 8px; font-size: 22px; font-weight: 850; white-space: nowrap; }
.markets { grid-template-columns: repeat(3, minmax(260px, 1fr)); margin-bottom: 16px; }
.market-card { padding: 13px; }
.market-top { display: flex; align-items: start; justify-content: space-between; gap: 10px; margin-bottom: 8px; }
.symbol { font-size: 16px; font-weight: 850; }
.price { font-size: 14px; font-weight: 800; color: var(--accent-2); text-align: right; }
canvas { width: 100%; height: 170px; background: #fbfdff; border: 1px solid var(--border); border-radius: 6px; display: block; }
.indicators {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
margin-top: 10px;
font-size: 12px;
}
.indicator { padding: 7px 8px; background: var(--panel-2); border-radius: 6px; min-width: 0; }
.indicator b { display: block; font-size: 11px; color: var(--muted); margin-bottom: 3px; }
.pattern-line {
display: flex;
justify-content: space-between;
gap: 10px;
margin: 8px 0 9px;
padding: 8px 9px;
background: #f8fafc;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 12px;
}
.pattern-line strong { color: var(--accent); }
.forecast-line {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 6px;
margin: -2px 0 9px;
font-size: 11px;
}
.forecast-chip {
min-width: 0;
padding: 6px 7px;
border: 1px solid var(--border);
border-radius: 6px;
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; }
table { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 13px; }
th, td { padding: 9px 10px; border-bottom: 1px solid var(--border); text-align: left; white-space: nowrap; }
th {
position: sticky;
top: 0;
z-index: 2;
background: #f8fafc;
color: var(--muted);
font-size: 11px;
text-transform: uppercase;
}
tr:last-child td { border-bottom: none; }
.positive { color: var(--green); font-weight: 800; }
.negative { color: var(--red); font-weight: 800; }
.stack { display: grid; gap: 14px; }
.config-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; font-size: 13px; }
.config-item { display: flex; justify-content: space-between; gap: 8px; border-bottom: 1px solid var(--border); padding: 7px 0; }
.learning-list { display: grid; gap: 8px; font-size: 13px; }
.learning-row { display: flex; justify-content: space-between; gap: 10px; border-bottom: 1px solid var(--border); padding: 7px 0; }
.learning-row span { color: var(--muted); }
.learning-state-line {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 0 0 10px;
border-bottom: 1px solid var(--border);
}
.state-pill {
display: inline-flex;
align-items: center;
min-height: 26px;
padding: 4px 8px;
border-radius: 6px;
font-size: 12px;
font-weight: 850;
white-space: nowrap;
}
.state-pill.ok { color: #075e42; background: #daf5ea; }
.state-pill.warn { color: #8a3b12; background: #ffedd5; }
.state-pill.bad { color: #991b1b; background: #fee2e2; }
.learning-metrics {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px 12px;
}
.learning-metric {
display: flex;
justify-content: space-between;
gap: 8px;
padding: 7px 0;
border-bottom: 1px solid var(--border);
min-width: 0;
}
.learning-metric span { color: var(--muted); }
.learning-metric strong { text-align: right; }
.learning-section-title {
margin-top: 6px;
color: var(--muted);
font-size: 11px;
font-weight: 850;
text-transform: uppercase;
}
.effect-list { display: grid; gap: 6px; }
.effect-row {
display: grid;
grid-template-columns: 72px 1fr auto;
gap: 8px;
align-items: center;
padding: 6px 0;
border-bottom: 1px solid var(--border);
min-width: 0;
}
.effect-row .small { color: var(--muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.reason-cell { max-width: 520px; min-width: 240px; white-space: normal; }
.muted { color: var(--muted); }
@media (max-width: 1180px) {
.stats { grid-template-columns: repeat(3, minmax(130px, 1fr)); }
.markets { grid-template-columns: repeat(2, minmax(260px, 1fr)); }
.layout { grid-template-columns: 1fr; }
}
@media (max-width: 720px) {
header { position: static; align-items: flex-start; padding: 14px; flex-direction: column; }
main { padding: 14px; }
.stats, .markets { grid-template-columns: 1fr; }
.value { font-size: 20px; }
.config-grid { grid-template-columns: 1fr; }
.learning-metrics { grid-template-columns: 1fr; }
.effect-row { grid-template-columns: 62px 1fr; }
.effect-row strong { text-align: left; }
}
</style>
</head>
<body>
<header>
<div>
<h1>Крипто спот-бот</h1>
<div class="subline" id="subline">Загрузка состояния...</div>
</div>
<div class="header-actions">
<span class="badge" id="modeBadge">Демо</span>
<span class="badge" id="liveBadge">Реальная торговля заблокирована</span>
<label class="switch-control" title="Переключает быстрый цикл принятия решений">
<input type="checkbox" id="fastToggle" data-testid="fast-toggle" />
<span class="switch" aria-hidden="true"></span>
<span id="fastToggleLabel">Быстрая торговля</span>
</label>
<span class="badge" id="fastBadge">Обычный режим</span>
<span class="badge" id="wsBadge">Поток данных</span>
<button class="primary" id="startBtn">Старт</button>
<button class="danger" id="stopBtn">Стоп</button>
</div>
</header>
<main>
<section class="grid stats">
<div class="stat"><div class="label">Баланс</div><div class="value" id="equity">-</div></div>
<div class="stat"><div class="label">Свободно</div><div class="value" id="cash">-</div></div>
<div class="stat"><div class="label">В позициях</div><div class="value" id="exposure">-</div></div>
<div class="stat"><div class="label">Прибыль/убыток</div><div class="value" id="pnl">-</div></div>
<div class="stat"><div class="label">Позиции</div><div class="value" id="positionsCount">-</div></div>
<div class="stat"><div class="label">Просадка</div><div class="value" id="drawdown">-</div></div>
</section>
<section class="grid markets" id="markets"></section>
<section class="grid layout">
<div class="stack">
<div class="panel">
<h2>Открытые позиции</h2>
<div class="table-wrap"><table><thead><tr><th>Пара</th><th>Кол-во</th><th>Вход</th><th>Цена</th><th>Прибыль/убыток</th><th>Стоп</th><th>Цель</th></tr></thead><tbody id="positionsTable"></tbody></table></div>
</div>
<div class="panel">
<h2>Сделки</h2>
<div class="table-wrap"><table><thead><tr><th>Время</th><th>Пара</th><th>Сторона</th><th>Кол-во</th><th>Вход</th><th>Выход</th><th>Итог</th><th>Причина</th></tr></thead><tbody id="tradesTable"></tbody></table></div>
</div>
<div class="panel">
<h2>Сигналы стратегии</h2>
<div class="table-wrap"><table><thead><tr><th>Время</th><th>Пара</th><th>Действие</th><th>Режим</th><th>Размер</th><th>Уверенность</th><th>База</th><th>Шаблон</th><th>Обучение</th><th>Прогноз</th><th>Отскок</th><th>Итог</th><th>Причина</th></tr></thead><tbody id="signalsTable"></tbody></table></div>
</div>
</div>
<aside class="stack">
<div class="panel">
<h2>Параметры</h2>
<div class="config-grid" id="configGrid"></div>
</div>
<div class="panel">
<h2>Обучение на сделках</h2>
<div class="learning-list" id="learningPanel"></div>
</div>
<div class="panel">
<h2>События</h2>
<div class="table-wrap"><table><thead><tr><th>Время</th><th>Уровень</th><th>Сообщение</th></tr></thead><tbody id="eventsTable"></tbody></table></div>
</div>
</aside>
</section>
</main>
<script>
const fmt = new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 4 });
const money = (value) => Number.isFinite(Number(value)) ? `${fmt.format(Number(value))} USDT` : '-';
const num = (value, digits = 4) => Number.isFinite(Number(value)) ? Number(value).toFixed(digits) : '-';
const time = (value) => value ? new Date(value).toLocaleString('ru-RU') : '-';
const modeName = (value) => ({ paper: 'Демо', live: 'Реальная торговля' }[value] || value || '-');
const actionName = (value) => ({ BUY: 'Купить', SELL: 'Продать', HOLD: 'Ждать' }[value] || value || '-');
const sideName = (value) => ({ BUY: 'Покупка', SELL: 'Продажа' }[value] || value || '-');
const levelName = (value) => ({ INFO: 'Инфо', WARN: 'Предупреждение', ERROR: 'Ошибка' }[value] || value || '-');
const yesNo = (value) => value ? 'Да' : 'Нет';
function messageText(value) {
return String(value || '')
.replaceAll('WebSocket Bybit подключен', 'Поток данных Bybit подключен')
.replaceAll('WebSocket Bybit отключен', 'Поток данных Bybit отключен')
.replaceAll('paper BUY', 'демо-покупка')
.replaceAll('paper SELL', 'демо-продажа')
.replaceAll('live shadow BUY', 'реальная покупка, локальная запись')
.replaceAll('live shadow SELL', 'реальная продажа, локальная запись')
.replaceAll('live BUY', 'реальная покупка')
.replaceAll('live SELL', 'реальная продажа')
.replaceAll('qty=', 'кол-во=')
.replaceAll('price=', 'цена=')
.replaceAll('conf=', 'уверенность=')
.replaceAll('net=', 'итог=')
.replaceAll('reason=', 'причина=')
.replaceAll('stop-loss', 'стоп-лосс')
.replaceAll('take-profit', 'тейк-профит')
.replaceAll('trailing stop', 'трейлинг-стоп');
}
async function fetchJson(url, options) {
const response = await fetch(url, options);
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
return response.json();
}
function setText(id, value) { document.getElementById(id).textContent = value; }
function signedClass(value) { return Number(value) >= 0 ? 'positive' : 'negative'; }
async function refresh() {
const [status, markets, trades, signals, events, config] = await Promise.all([
fetchJson('/api/status'),
fetchJson('/api/markets'),
fetchJson('/api/trades?limit=80'),
fetchJson('/api/signals?limit=120'),
fetchJson('/api/events?limit=80'),
fetchJson('/api/config')
]);
renderStatus(status, markets);
renderMarkets(markets);
renderPositions(status.positions || []);
renderTrades(trades.items || []);
renderSignals(signals.items || []);
renderEvents(events.items || []);
renderConfig(config);
renderFastMode(config);
renderLearning(status.learning || {}, signals.items || [], config);
}
function renderStatus(payload, markets) {
const s = payload.status;
const a = payload.account;
setText('subline', `${s.running ? 'Работает' : 'Остановлен'} · ${s.symbols.join(', ') || 'пары не выбраны'} · цикл ${time(s.last_loop_at)}`);
setText('equity', money(a.equity));
setText('cash', money(a.cash));
setText('exposure', money(a.exposure));
setText('pnl', `${num(a.net_pnl, 4)} (${num(a.net_pnl_percent, 2)}%)`);
document.getElementById('pnl').className = `value ${signedClass(a.net_pnl)}`;
setText('positionsCount', String((payload.positions || []).length));
setText('drawdown', money(a.drawdown));
const modeBadge = document.getElementById('modeBadge');
modeBadge.textContent = modeName(s.mode);
modeBadge.className = `badge ${s.mode === 'paper' ? 'ok' : 'warn'}`;
const liveBadge = document.getElementById('liveBadge');
liveBadge.textContent = s.live_trading_ready ? 'Реальная торговля разрешена' : 'Реальная торговля заблокирована';
liveBadge.className = `badge ${s.live_trading_ready ? 'warn' : 'ok'}`;
const wsBadge = document.getElementById('wsBadge');
wsBadge.textContent = markets.ws_connected ? 'Поток данных подключен' : 'Поток данных отключен';
wsBadge.className = `badge ${markets.ws_connected ? 'ok' : 'warn'}`;
}
function renderMarkets(payload) {
const root = document.getElementById('markets');
root.innerHTML = '';
for (const market of payload.markets || []) {
const ticker = market.ticker;
const pattern = market.pattern || {};
const last = market.candles?.[market.candles.length - 1] || {};
const card = document.createElement('article');
card.className = 'market-card';
const symbol = ticker?.symbol || market.instrument?.symbol || '-';
card.innerHTML = `
<div class="market-top">
<div><div class="symbol">${symbol}</div><div class="muted">Спред ${num(ticker?.spread_percent, 4)}%</div></div>
<div class="price">${money(ticker?.last_price)}</div>
</div>
<div class="pattern-line">
<span>Шаблон: <strong>${escapeHtml(pattern.label || 'нет данных')}</strong></span>
<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>
<div class="indicator"><b>EMA50</b>${num(last.ema_50, 4)}</div>
<div class="indicator"><b>EMA200</b>${num(last.ema_200, 4)}</div>
<div class="indicator"><b>ATR14</b>${num(last.atr_14, 4)}</div>
<div class="indicator"><b>Объем MA20</b>${num(last.volume_ma_20, 4)}</div>
<div class="indicator"><b>Оборот 24ч</b>${money(ticker?.turnover_24h)}</div>
</div>
`;
root.appendChild(card);
drawCandles(card.querySelector('canvas'), market.candles || []);
}
}
function modelName(model) {
const key = String(model || '').toLowerCase();
const names = {
torch_lstm: 'PyTorch LSTM',
torch_gru: 'PyTorch GRU',
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 `<div class="forecast-line"><div class="forecast-chip"><b>Прогноз</b>${escapeHtml(modelReason(forecast?.reason || 'нет данных'))}</div></div>`;
}
return `<div class="forecast-line">
<div class="forecast-chip"><b>Модель</b>${escapeHtml(modelName(forecast.model || '-'))}</div>
<div class="forecast-chip"><b>Горизонт</b>${num(forecast.horizon || 0, 0)}ч</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>Q10/Q50/Q90</b>${signedNum(forecast.quantile_10_percent, 2)} / ${signedNum(forecast.quantile_50_percent, 2)} / ${signedNum(forecast.quantile_90_percent, 2)}%</div>
<div class="forecast-chip"><b>Волат.</b>${num(forecast.volatility_percent, 3)}%</div>
</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;
ctx.clearRect(0, 0, w, h);
ctx.fillStyle = '#fbfdff';
ctx.fillRect(0, 0, w, h);
const data = candles.slice(-80);
if (data.length < 2) return;
const highs = data.map(c => c.high).concat(data.map(c => c.ema_50 || c.close), data.map(c => c.ema_200 || c.close));
const lows = data.map(c => c.low).concat(data.map(c => c.ema_50 || c.close), data.map(c => c.ema_200 || c.close));
const max = Math.max(...highs), min = Math.min(...lows);
const pad = Math.max((max - min) * 0.08, max * 0.0008);
const y = (v) => h - 18 - ((v - min + pad) / (max - min + pad * 2)) * (h - 34);
const step = w / data.length;
ctx.strokeStyle = '#e2e8f0';
ctx.lineWidth = 1;
for (let i = 0; i < 4; i++) {
const yy = 16 + i * ((h - 34) / 3);
ctx.beginPath(); ctx.moveTo(0, yy); ctx.lineTo(w, yy); ctx.stroke();
}
data.forEach((c, i) => {
const x = i * step + step / 2;
const up = c.close >= c.open;
ctx.strokeStyle = up ? '#0f9f6e' : '#d64545';
ctx.fillStyle = ctx.strokeStyle;
ctx.beginPath(); ctx.moveTo(x, y(c.high)); ctx.lineTo(x, y(c.low)); ctx.stroke();
const bodyY = Math.min(y(c.open), y(c.close));
const bodyH = Math.max(2, Math.abs(y(c.open) - y(c.close)));
ctx.fillRect(x - Math.max(2, step * 0.28), bodyY, Math.max(3, step * 0.56), bodyH);
});
drawLine(ctx, data, 'ema_50', '#2563eb', y, step);
drawLine(ctx, data, 'ema_200', '#7c3aed', y, step);
}
function drawLine(ctx, data, key, color, y, step) {
ctx.strokeStyle = color;
ctx.lineWidth = 1.5;
ctx.beginPath();
let started = false;
data.forEach((c, i) => {
if (!c[key]) return;
const x = i * step + step / 2;
if (!started) { ctx.moveTo(x, y(c[key])); started = true; }
else ctx.lineTo(x, y(c[key]));
});
if (started) ctx.stroke();
}
function renderPositions(items) {
document.getElementById('positionsTable').innerHTML = items.map(p => `
<tr><td>${p.symbol}</td><td>${num(p.qty, 8)}</td><td>${num(p.entry_price, 6)}</td><td>${num(p.mark_price, 6)}</td><td class="${signedClass(p.unrealized_pnl)}">${num(p.unrealized_pnl, 4)}</td><td>${num(p.stop_loss, 6)}</td><td>${num(p.take_profit, 6)}</td></tr>
`).join('') || `<tr><td colspan="7" class="muted">Нет открытых позиций</td></tr>`;
}
function renderTrades(items) {
document.getElementById('tradesTable').innerHTML = items.map(t => `
<tr><td>${time(t.closed_at || t.opened_at)}</td><td>${t.symbol}</td><td>${sideName(t.side)}</td><td>${num(t.qty, 8)}</td><td>${num(t.entry_price, 6)}</td><td>${num(t.exit_price, 6)}</td><td class="${signedClass(t.net_pnl)}">${num(t.net_pnl, 4)}</td><td>${escapeHtml(t.reason || '')}</td></tr>
`).join('') || `<tr><td colspan="8" class="muted">Сделок пока нет</td></tr>`;
}
function renderSignals(items) {
document.getElementById('signalsTable').innerHTML = items.map(s => `
${signalRowHtml(s)}
`).join('');
}
function signalRowHtml(signal) {
const d = parseDiagnostics(signal);
return `<tr>
<td>${time(signal.created_at)}</td>
<td>${signal.symbol}</td>
<td>${actionName(signal.action)}</td>
<td>${escapeHtml(d.trade_mode || '-')}</td>
<td>${money(d.position_notional_usdt)}</td>
<td>${num(signal.confidence, 3)}</td>
<td>${num(d.base_score, 3)}</td>
<td class="${signedClass(d.pattern_adjustment || 0)}">${signedNum(d.pattern_adjustment, 3)}</td>
<td class="${signedClass(d.learning_adjustment || 0)}">${signedNum(d.learning_adjustment, 3)}</td>
${forecastSignalCell(d.forecast || {}, d.forecast_adjustment)}
<td>${num(d.rebound_probability, 3)}</td>
<td>${num(d.final_score, 3)}</td>
<td>${escapeHtml(signal.reason || '')}</td>
</tr>`;
}
function renderEvents(items) {
document.getElementById('eventsTable').innerHTML = items.map(e => `
<tr><td>${time(e.created_at)}</td><td>${levelName(e.level)}</td><td>${escapeHtml(messageText(e.message))}</td></tr>
`).join('');
}
function forecastSignalCell(forecast, adjustment) {
if (!forecast || !forecast.model) {
return '<td>-</td>';
}
const expected = Number(forecast.expected_return_percent);
const probability = Number(forecast.probability_up);
const skill = Number(forecast.skill);
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-';
const adjustmentText = Number.isFinite(Number(adjustment)) && Number(adjustment) !== 0
? ` · ${signedNum(adjustment, 3)}`
: '';
return `<td class="${signedClass(expected || 0)}">${model} ${expectedText} · ${probabilityText} · ${skillText}${adjustmentText}</td>`;
}
function renderConfig(config) {
const keys = [
['Режим', modeName(config.trading_mode)],
['Стратегия', config.strategy_mode || '-'],
['Стартовый баланс', money(config.starting_balance_usdt)],
['Мин. уверенность', config.min_signal_confidence],
['Быстрая торговля', yesNo(config.fast_trading_enabled)],
['Цикл решений', `${num(config.effective_loop_interval_seconds, 2)}с`],
['Пауза входа', `${config.effective_entry_cooldown_seconds}с`],
['Новых входов/мин', config.max_entries_per_minute],
['Анализ шаблонов', yesNo(config.pattern_analysis_enabled)],
['Вес шаблонов', config.pattern_score_weight],
['Обучение', yesNo(config.learning_enabled)],
['Сделок для обучения', `${config.learning_lookback_trades}, мин. ${config.learning_min_samples}`],
['Макс. спред', `${config.max_spread_percent}%`],
['Мин. оборот 24ч', money(config.min_24h_turnover_usdt)],
['Размер позиции', `${money(config.min_position_usdt)} - ${money(config.max_position_usdt)}`],
['Лимит на пару', money(config.max_symbol_exposure_usdt)],
['Риск на сделку', `${num((config.risk_per_trade_percent || 0) * 100, 2)}% equity`],
['RSI входа', `${num(config.trend_rsi_min, 1)} - ${num(config.trend_rsi_max, 1)}`],
['Лимит в позициях', money(config.max_total_exposure_usdt)],
['Лимит позиций', `${config.max_open_positions} всего / ${config.max_positions_per_symbol} на пару`],
['Стоп', `${num(config.stop_loss_percent * 100, 2)}%`],
['ATR trailing', `${num(config.atr_trailing_multiplier, 2)} ATR`],
['Удержание / пауза', `${config.min_hold_seconds}с / ${config.entry_cooldown_seconds}с`],
['Комиссия / проскальзывание', `${num(config.taker_fee_rate * 100, 3)}% / ${num(config.slippage_rate * 100, 3)}%`],
['Таймфрейм', `${config.base_interval} / тренд ${config.trend_interval}`],
['Поток данных', yesNo(config.websocket_enabled)]
];
document.getElementById('configGrid').innerHTML = keys.map(([k, v]) => `
<div class="config-item"><span class="muted">${escapeHtml(k)}</span><strong>${escapeHtml(String(v))}</strong></div>
`).join('');
}
function renderFastMode(config) {
const fastBadge = document.getElementById('fastBadge');
fastBadge.textContent = config.fast_trading_enabled
? `Быстрый режим · ${num(config.effective_loop_interval_seconds, 2)}с`
: 'Обычный режим';
fastBadge.className = `badge ${config.fast_trading_enabled ? 'warn' : 'ok'}`;
const fastToggle = document.getElementById('fastToggle');
const fastToggleLabel = document.getElementById('fastToggleLabel');
fastToggle.checked = Boolean(config.fast_trading_enabled);
fastToggle.disabled = false;
fastToggleLabel.textContent = config.fast_trading_enabled
? 'Быстрая торговля: вкл'
: 'Быстрая торговля: выкл';
}
function renderLearning(learning, signals, config) {
const root = document.getElementById('learningPanel');
const patternStats = learning.pattern_stats || {};
const effects = strategyEffects(signals);
const learningEffects = effects.filter(item => Number.isFinite(item.learningAdjustment));
const activeLearning = learningEffects.filter(item => item.learningAdjustment !== 0);
const patternEffects = effects.filter(item => Number.isFinite(item.patternAdjustment) && item.patternAdjustment !== 0);
const avgLearning = average(activeLearning.map(item => item.learningAdjustment));
const lastLearning = activeLearning[0];
const ready = Boolean(learning.enabled) && Number(learning.sample_size || 0) >= Number(config.learning_min_samples || 0);
const losing = Number(learning.net_pnl || 0) < 0;
const stateClass = !learning.enabled ? 'warn' : ready ? (losing ? 'bad' : 'ok') : 'warn';
const stateText = !learning.enabled
? 'Выключено'
: ready
? (losing ? 'Работает, статистика минусовая' : 'Работает')
: 'Мало данных';
const stateHint = ready
? `${learning.sample_size || 0} закрытых сделок из окна ${config.learning_lookback_trades || '-'}`
: `${learning.sample_size || 0}/${config.learning_min_samples || 0} сделок до первых выводов`;
const rows = Object.entries(patternStats)
.sort((a, b) => (b[1].sample_size || 0) - (a[1].sample_size || 0))
.slice(0, 5);
const rules = learning.adaptive_rules || {};
const validation = rules.validation || {};
const ruleRows = [
['Разрешение торговли', rules.trade_permission || 'normal'],
['Режим риска', rules.risk_mode || 'neutral'],
['Экспозиция / цель', `${money(rules.current_total_exposure_usdt || 0)} / ${money(rules.target_total_exposure_usdt || config.max_total_exposure_usdt || 0)}`],
['Порог входа', signedNum(Number(rules.entry_threshold_adjustment || 0), 4)],
['Множитель Kelly', `${num(rules.effective_position_size_multiplier || rules.position_size_multiplier || 1, 2)}x`],
['Мин. удержание', `${rules.min_hold_seconds || config.min_hold_seconds || '-'} сек`],
['EMA-выход', exitRuleName(rules.ema_exit_mode)],
['RSI-выход', exitRuleName(rules.rsi_exit_mode)],
['Мин. прибыль выхода', `${num(rules.min_exit_profit_percent || 0, 3)}%`],
['Стоп / цель обучения', `${num((rules.stop_loss_percent || config.stop_loss_percent || 0) * 100, 2)}% / ${num((rules.take_profit_percent || config.take_profit_percent || 0) * 100, 2)}%`],
['Проверка правил', `${validation.status || '-'} / ${num(validation.avoided_loss_usdt || 0, 4)} USDT`],
];
const base = [
['Закрытых сделок', `${learning.sample_size || 0} / ${config.learning_lookback_trades || '-'}`],
['Итог обучения', `${num(learning.net_pnl, 4)} USDT`],
['Win rate', `${num((learning.win_rate || 0) * 100, 1)}%`],
['Активные поправки', `${activeLearning.length} из ${learningEffects.length}`],
['Средняя поправка', signedNum(avgLearning, 4)],
['Шаблоны влияют', `${patternEffects.length} сигналов`],
['Последняя поправка', lastLearning ? `${lastLearning.symbol} ${signedNum(lastLearning.learningAdjustment, 4)}` : 'нет'],
['Блокировки входа', `${effects.filter(item => item.blockedByPattern || item.blockedByLearning || item.blockedByAdaptive || item.blockedByForecast).length} сигналов`],
];
const stateHtml = `
<div class="learning-state-line">
<span class="state-pill ${stateClass}">${stateText}</span>
<span class="muted">${escapeHtml(stateHint)}</span>
</div>`;
const statHtml = `<div class="learning-metrics">${base.map(([k, v]) => `
<div class="learning-metric"><span>${k}</span><strong>${v}</strong></div>
`).join('')}</div>`;
const effectHtml = activeLearning.length
? `<div class="learning-section-title">Последние поправки обучения</div><div class="effect-list">${activeLearning.slice(0, 5).map(item => `
<div class="effect-row"><strong>${item.symbol}</strong><span class="small">${escapeHtml(item.pattern || item.reason || '-')}</span><strong class="${signedClass(item.learningAdjustment)}">${signedNum(item.learningAdjustment, 4)}</strong></div>
`).join('')}</div>`
: '<div class="muted">В последних сигналах активных поправок обучения нет.</div>';
const ruleHtml = `<div class="learning-section-title">Адаптивные правила</div>${ruleRows.map(([k, v]) => `
<div class="learning-row"><span>${escapeHtml(k)}</span><strong>${escapeHtml(String(v))}</strong></div>
`).join('')}`;
const patternHtml = rows.length
? `<div class="learning-section-title">Статистика по шаблонам</div>${rows.map(([name, stat]) => `
<div class="learning-row"><span>${escapeHtml(name)}</span><strong class="${signedClass(stat.net_pnl)}">${num(stat.net_pnl, 4)} / ${num((stat.win_rate || 0) * 100, 1)}%</strong></div>
`).join('')}`
: '<div class="muted">Закрытых сделок пока мало; статистика шаблонов не сформирована.</div>';
root.innerHTML = stateHtml + statHtml + ruleHtml + effectHtml + patternHtml;
}
function strategyEffects(signals) {
return signals.map(signal => {
const d = parseDiagnostics(signal);
const pattern = d.pattern || {};
const learning = d.learning || {};
return {
symbol: signal.symbol,
pattern: pattern.label || '',
reason: learning.reason || '',
baseScore: numberOrNull(d.base_score),
patternAdjustment: numberOrNull(d.pattern_adjustment),
learningAdjustment: numberOrNull(d.learning_adjustment),
forecastAdjustment: numberOrNull(d.forecast_adjustment),
finalScore: numberOrNull(d.final_score),
blockedByPattern: Boolean(d.entry_blocked_by_pattern),
blockedByLearning: Boolean(d.entry_blocked_by_learning),
blockedByAdaptive: Boolean(d.entry_blocked_by_adaptive_rules),
blockedByForecast: Boolean(d.entry_blocked_by_forecast),
};
}).filter(item => (
item.baseScore !== null
|| item.patternAdjustment !== null
|| item.learningAdjustment !== null
|| item.forecastAdjustment !== null
|| item.finalScore !== null
));
}
function parseDiagnostics(signal) {
try {
return JSON.parse(signal.diagnostics_json || '{}');
} catch (_) {
return {};
}
}
function exitRuleName(value) {
return value === 'profit_only' ? 'только при прибыли' : 'обычный';
}
function numberOrNull(value) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
function average(values) {
const filtered = values.filter(value => Number.isFinite(value));
return filtered.length ? filtered.reduce((sum, value) => sum + value, 0) / filtered.length : 0;
}
function signedNum(value, digits = 4) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return '-';
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]));
}
document.getElementById('startBtn').addEventListener('click', async () => { await fetchJson('/api/control/start', { method: 'POST' }); await refresh(); });
document.getElementById('stopBtn').addEventListener('click', async () => { await fetchJson('/api/control/stop', { method: 'POST' }); await refresh(); });
document.getElementById('fastToggle').addEventListener('change', async (event) => {
const enabled = event.target.checked;
event.target.disabled = true;
setText('fastToggleLabel', enabled ? 'Включение...' : 'Выключение...');
try {
await fetchJson('/api/config/fast-trading', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled })
});
await refresh();
} catch (err) {
event.target.checked = !enabled;
event.target.disabled = false;
setText('fastToggleLabel', enabled ? 'Быстрая торговля: выкл' : 'Быстрая торговля: вкл');
setText('subline', `Ошибка переключения быстрой торговли: ${err.message}`);
}
});
refresh().catch(err => setText('subline', `Ошибка загрузки: ${err.message}`));
setInterval(() => refresh().catch(err => setText('subline', `Ошибка обновления: ${err.message}`)), 3000);
</script>
</body>
</html>
"""