Files
TradeBot/crypto_spot_bot/dashboard.py
T
2026-06-26 20:42:01 +03:00

1865 lines
78 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.analytics import analytics_snapshot
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.reconciliation import reconciliation_snapshot
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/analytics")
async def analytics() -> dict[str, Any]:
return analytics_snapshot(settings, storage)
@app.get("/api/quality")
async def quality() -> dict[str, Any]:
return market.snapshot().get("quality", {})
@app.get("/api/reconciliation")
async def reconciliation() -> dict[str, Any]:
return reconciliation_snapshot(
settings=settings,
storage=storage,
client=client,
instruments=market.instruments,
)
@app.get("/api/backtest")
async def backtest() -> dict[str, Any]:
return _runtime_json(settings, "torch_threshold_calibration.json")
@app.get("/api/retrain")
async def retrain() -> dict[str, Any]:
return _runtime_json(settings, "torch_retrain_guard.json")
@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,
"risk_guard_enabled": settings.risk_guard_enabled,
"risk_recent_trade_window": settings.risk_recent_trade_window,
"risk_max_consecutive_losses": settings.risk_max_consecutive_losses,
"risk_min_recent_profit_factor": settings.risk_min_recent_profit_factor,
"risk_reduce_multiplier": settings.risk_reduce_multiplier,
"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_min_probability_up": settings.time_series_min_probability_up,
"time_series_min_confidence": settings.time_series_min_confidence,
"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_probe_enabled": settings.time_series_probe_enabled,
"time_series_probe_min_edge_percent": settings.time_series_probe_min_edge_percent,
"time_series_probe_min_probability_up": settings.time_series_probe_min_probability_up,
"time_series_probe_size_multiplier": settings.time_series_probe_size_multiplier,
"time_series_rebound_fallback_enabled": settings.time_series_rebound_fallback_enabled,
"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 _runtime_json(settings: Settings, name: str) -> dict[str, Any]:
path = settings.time_series_lstm_model_path.parent / name
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {"available": False, "path": str(path)}
if not isinstance(data, dict):
return {"available": False, "path": str(path)}
data["available"] = True
data["path"] = str(path)
return data
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>TradeBot - русская панель</title>
<style>
:root {
--page: #f3f5f7;
--panel: #ffffff;
--panel-soft: #f8fafb;
--line: #d8e0e7;
--line-strong: #b8c4cf;
--text: #151b23;
--muted: #657384;
--green: #11875d;
--red: #c3423f;
--blue: #2563eb;
--amber: #ad7418;
--chart-bg: #0f141b;
--chart-grid: #263241;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--page);
color: var(--text);
font-family: Inter, "Segoe UI", Roboto, Arial, sans-serif;
font-size: 14px;
letter-spacing: 0;
}
header {
position: sticky;
top: 0;
z-index: 30;
background: rgba(243, 245, 247, 0.98);
border-bottom: 1px solid var(--line);
backdrop-filter: blur(8px);
}
.topbar {
max-width: 1680px;
margin: 0 auto;
padding: 14px 18px 10px;
display: grid;
grid-template-columns: minmax(240px, 1fr) auto;
gap: 16px;
align-items: center;
}
h1 {
margin: 0;
font-size: 20px;
line-height: 1.2;
font-weight: 750;
}
.subtitle {
color: var(--muted);
margin-top: 4px;
font-size: 13px;
line-height: 1.35;
}
.actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
align-items: center;
}
button,
.switch {
height: 34px;
border: 1px solid var(--line-strong);
background: var(--panel);
color: var(--text);
border-radius: 6px;
padding: 0 12px;
font: inherit;
font-weight: 650;
cursor: pointer;
white-space: nowrap;
}
button.primary {
background: #0f766e;
border-color: #0f766e;
color: #ffffff;
}
button.danger { color: var(--red); }
button:hover { border-color: #8492a1; }
.switch {
display: inline-flex;
align-items: center;
gap: 8px;
cursor: default;
}
.switch input { margin: 0; }
nav {
max-width: 1680px;
margin: 0 auto;
padding: 0 18px 12px;
display: flex;
gap: 6px;
overflow-x: auto;
}
.tab {
background: transparent;
border-color: transparent;
border-radius: 6px;
color: #334155;
}
.tab.active {
background: var(--panel);
border-color: var(--line-strong);
color: var(--text);
}
main {
max-width: 1680px;
margin: 0 auto;
padding: 16px 18px 26px;
}
.layout {
display: grid;
gap: 14px;
}
.section,
.market {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
}
.section-head,
.market-head {
min-height: 42px;
border-bottom: 1px solid var(--line);
padding: 10px 12px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.section-title,
.market-title {
margin: 0;
font-size: 15px;
font-weight: 750;
line-height: 1.25;
}
.section-body,
.market-body { padding: 12px; }
.grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.grid.two { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.grid.three { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.metric {
border: 1px solid var(--line);
border-radius: 6px;
background: var(--panel-soft);
padding: 10px;
min-height: 76px;
}
.metric .name {
color: var(--muted);
font-size: 12px;
line-height: 1.3;
margin-bottom: 6px;
}
.metric .value {
font-size: 19px;
line-height: 1.2;
font-weight: 760;
overflow-wrap: anywhere;
}
.metric .hint {
color: var(--muted);
font-size: 12px;
line-height: 1.35;
margin-top: 5px;
}
.markets {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.market-body {
display: grid;
grid-template-columns: minmax(420px, 1.45fr) minmax(300px, .95fr);
gap: 12px;
align-items: start;
}
.chart-box {
border: 1px solid #1f2937;
border-radius: 6px;
background: var(--chart-bg);
overflow: hidden;
min-height: 336px;
}
canvas.exchange-chart {
display: block;
width: 100%;
height: 330px;
}
.chart-legend {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
padding: 7px 10px;
color: #cbd5e1;
border-top: 1px solid #1f2937;
font-size: 12px;
line-height: 1.3;
}
.legend-item {
display: inline-flex;
align-items: center;
gap: 5px;
}
.legend-line {
width: 18px;
height: 2px;
display: inline-block;
background: #93c5fd;
}
.legend-line.ema200 { background: #f59e0b; }
.legend-line.price { background: #e5e7eb; border-top: 1px dashed #e5e7eb; height: 1px; }
.details {
display: grid;
gap: 10px;
}
.subsection {
border: 1px solid var(--line);
border-radius: 6px;
overflow: hidden;
background: #ffffff;
}
.subsection h3 {
margin: 0;
padding: 9px 10px;
border-bottom: 1px solid var(--line);
background: var(--panel-soft);
font-size: 13px;
line-height: 1.25;
}
.kv {
display: grid;
grid-template-columns: minmax(130px, .95fr) minmax(110px, 1fr);
gap: 8px;
padding: 7px 10px;
border-top: 1px solid #edf1f4;
align-items: start;
}
.kv:first-child { border-top: 0; }
.kv .key {
color: var(--muted);
line-height: 1.35;
}
.kv .val {
font-weight: 650;
line-height: 1.35;
text-align: right;
overflow-wrap: anywhere;
}
.status,
.flag {
display: inline-flex;
align-items: center;
min-height: 24px;
padding: 3px 7px;
border: 1px solid var(--line-strong);
border-radius: 4px;
background: #f8fafc;
color: #334155;
font-weight: 700;
font-size: 12px;
line-height: 1.25;
}
.status.ok,
.flag.ok {
color: var(--green);
background: #edf8f2;
border-color: #9ed3bd;
}
.status.warn,
.flag.warn {
color: var(--amber);
background: #fff8e8;
border-color: #e6c78a;
}
.status.bad,
.flag.bad {
color: var(--red);
background: #fff1f0;
border-color: #eba4a0;
}
.flags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.table-wrap {
overflow: auto;
border: 1px solid var(--line);
border-radius: 6px;
background: #ffffff;
}
table {
width: 100%;
border-collapse: collapse;
min-width: 620px;
}
th,
td {
padding: 8px 9px;
border-bottom: 1px solid #edf1f4;
text-align: left;
vertical-align: top;
line-height: 1.35;
}
th {
position: sticky;
top: 0;
z-index: 1;
background: #f6f8fa;
color: #475569;
font-size: 12px;
font-weight: 760;
}
td.num,
th.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
tr:last-child td { border-bottom: 0; }
.feature-table table { min-width: 760px; }
.feature-table td:nth-child(5) { min-width: 250px; }
.mono {
font-family: "Cascadia Mono", Consolas, "SFMono-Regular", monospace;
font-variant-numeric: tabular-nums;
}
.muted { color: var(--muted); }
.green { color: var(--green); }
.red { color: var(--red); }
.amber { color: var(--amber); }
.blue { color: var(--blue); }
.empty {
padding: 18px 12px;
border: 1px solid var(--line);
border-radius: 6px;
background: #ffffff;
color: var(--muted);
text-align: center;
}
.wide { grid-column: 1 / -1; }
.compact-list {
display: grid;
gap: 8px;
}
.note {
color: var(--muted);
font-size: 12px;
line-height: 1.4;
margin-top: 7px;
}
@media (max-width: 1280px) {
.markets { grid-template-columns: 1fr; }
.grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 900px) {
.topbar { grid-template-columns: 1fr; }
.actions { justify-content: flex-start; }
.market-body { grid-template-columns: 1fr; }
.chart-box { min-height: 286px; }
canvas.exchange-chart { height: 280px; }
}
@media (max-width: 640px) {
main { padding: 12px 10px 20px; }
.topbar { padding: 12px 10px 8px; }
nav { padding: 0 10px 10px; }
.grid,
.grid.two,
.grid.three { grid-template-columns: 1fr; }
.kv { grid-template-columns: 1fr; }
.kv .val { text-align: left; }
button, .switch { width: auto; }
}
</style>
</head>
<body>
<header>
<div class="topbar">
<div>
<h1>TradeBot: торговая панель</h1>
<div id="headline" class="subtitle">Загружаю состояние бота, рынки и прогнозы Torch...</div>
</div>
<div class="actions">
<span id="healthStatus" class="status warn">Проверка</span>
<button id="startBtn" class="primary" type="button">Запустить</button>
<button id="stopBtn" class="danger" type="button">Остановить</button>
<label class="switch"><input id="fastToggle" type="checkbox" /> быстрый цикл</label>
</div>
</div>
<nav id="tabs"></nav>
</header>
<main id="app"></main>
<script>
const state = {
activeTab: 'overview',
data: {},
latestSignalsBySymbol: {},
latestDiagnosticsBySymbol: {},
};
const TABS = [
['overview', 'Обзор'],
['markets', 'Рынки и Torch'],
['trades', 'Сделки'],
['risk', 'Риск и качество'],
['model', 'Проверка модели'],
['logs', 'Журнал'],
['settings', 'Настройки'],
];
const CONFIG_LABELS = {
trading_mode: ['Режим торговли', 'paper = симуляция, live = реальные ордера'],
bybit_testnet: ['Тестовая сеть Bybit', 'Если включено, бот работает с testnet'],
starting_balance_usdt: ['Стартовый депозит', 'Только для paper-режима'],
auto_select_symbols: ['Автовыбор пар', 'Сейчас должен быть выключен: пары зафиксированы вручную'],
top_symbols_count: ['Лимит автопар', 'Используется только при автовыборе'],
symbols: ['Торговые пары', 'Список монет, которые бот имеет право торговать'],
strategy_mode: ['Основная стратегия', 'torch_forecast = решение принимает Torch-прогноз'],
base_interval: ['Таймфрейм входа', 'Свечи, на которых бот принимает торговое решение'],
kline_limit: ['Глубина 1h свечей', 'Сколько последних свечей хранится для расчёта признаков'],
trend_interval: ['Таймфрейм тренда', 'Старший таймфрейм для фильтров качества рынка'],
trend_kline_limit: ['Глубина трендовых свечей', 'Сколько свечей старшего таймфрейма запрашивается'],
loop_interval_seconds: ['Обычный цикл бота, сек', 'Пауза между решениями без быстрого режима'],
fast_trading_enabled: ['Быстрый цикл включён', 'Переключатель ускоренного принятия решений'],
fast_loop_interval_seconds: ['Быстрый цикл, сек', 'Пауза между решениями в быстром режиме'],
effective_loop_interval_seconds: ['Фактический цикл, сек', 'Реальная текущая пауза с учётом быстрого режима'],
fast_entry_cooldown_seconds: ['Быстрый cooldown входа, сек', 'Минимальная пауза между входами в быстром режиме'],
effective_entry_cooldown_seconds: ['Фактический cooldown входа, сек', 'Текущая защита от слишком частых входов'],
max_entries_per_minute: ['Максимум входов в минуту', 'Общий ограничитель частоты покупок'],
websocket_enabled: ['WebSocket включён', 'Онлайн-обновление рынка через поток Bybit'],
min_signal_confidence: ['Минимальная уверенность сигнала', 'Нижняя планка для торгового решения'],
max_spread_percent: ['Максимальный спред, %', 'Если спред шире, вход блокируется'],
min_24h_turnover_usdt: ['Минимальный оборот 24ч', 'Фильтр ликвидности пары'],
pattern_analysis_enabled: ['Анализ паттернов включён', 'Дополнительный старый блок паттернов'],
pattern_score_weight: ['Вес паттернов', 'Насколько паттерны влияют на итоговый балл'],
learning_enabled: ['Adaptive learning включён', 'Адаптация по истории сделок'],
learning_lookback_trades: ['Глубина learning, сделок', 'Сколько сделок смотреть для адаптации'],
learning_min_samples: ['Минимум сделок для learning', 'Меньше этого адаптация не считается надёжной'],
learning_max_adjustment: ['Максимальная поправка learning', 'Ограничение влияния адаптации'],
learning_max_position_multiplier: ['Максимальный множитель learning', 'Верхний предел усиления хороших сетапов'],
min_position_usdt: ['Минимальный размер покупки', 'Меньше этого бот не открывает позицию'],
max_position_usdt: ['Максимальный размер одной покупки', 'Потолок одного входа'],
max_symbol_exposure_usdt: ['Максимум в одной паре', 'Сколько USDT можно держать в одной монете суммарно'],
max_total_exposure_usdt: ['Максимальная общая экспозиция', 'Сколько USDT можно держать во всех позициях'],
max_open_positions: ['Максимум открытых позиций', 'Общий лимит одновременно открытых покупок'],
max_positions_per_symbol: ['Максимум покупок в одной паре', 'Разрешает несколько входов в одну монету'],
grid_trading_enabled: ['Grid включён', 'Должен быть выключен, если ориентир только Torch'],
grid_entry_confidence: ['Уверенность grid', 'Порог старого grid-режима'],
grid_buy_zone: ['Зона покупки grid', 'Параметр старого grid-режима'],
grid_max_position_usdt: ['Максимум grid-позиции', 'Ограничение старого grid-режима'],
rebound_trading_enabled: ['Rebound включён', 'Дополнительный вход на отскоке'],
rebound_entry_confidence: ['Уверенность rebound', 'Порог отскока'],
rebound_min_probability: ['Минимальная вероятность rebound', 'Нижняя планка для отскока'],
rebound_max_position_usdt: ['Максимум rebound-позиции', 'Потолок размера отскока'],
kelly_sizing_enabled: ['Kelly sizing включён', 'Размер покупки считается по формуле Келли'],
kelly_fraction: ['Доля Kelly', 'Какую часть полного Kelly разрешено использовать'],
kelly_max_fraction: ['Максимальная доля депозита Kelly', 'Потолок капитала на одну идею'],
risk_per_trade_percent: ['Риск на сделку', 'Доля депозита, которую можно потерять по стопу'],
risk_guard_enabled: ['Risk guard включён', 'Снижает риск при плохой серии сделок'],
risk_recent_trade_window: ['Окно risk guard', 'Сколько последних сделок учитывать'],
risk_max_consecutive_losses: ['Максимум убытков подряд', 'После такой серии риск режется'],
risk_min_recent_profit_factor: ['Минимальный profit factor', 'Если хуже, риск снижается'],
risk_reduce_multiplier: ['Множитель снижения риска', 'Во сколько раз уменьшать размер'],
atr_trailing_multiplier: ['ATR trailing множитель', 'Расстояние трейлинг-стопа в ATR'],
trend_rsi_min: ['Нижняя граница RSI', 'Фильтр перегретости/слабости рынка'],
trend_rsi_max: ['Верхняя граница RSI', 'Фильтр перегретости/слабости рынка'],
time_series_forecast_enabled: ['Torch-прогноз включён', 'Главный прогнозный блок'],
time_series_min_candles: ['Минимум свечей для Torch', 'Без этой истории прогноз считается ненадёжным'],
time_series_forecast_horizon: ['Горизонт прогноза', 'На сколько свечей вперёд смотрит модель'],
time_series_min_edge_percent: ['Минимальный edge, %', 'Ожидаемое движение, ниже которого полный вход запрещён'],
time_series_min_probability_up: ['Минимальная P(up)', 'Минимальная вероятность роста'],
time_series_min_confidence: ['Минимальная confidence', 'Минимальная уверенность итогового сигнала'],
time_series_max_adjustment: ['Максимальная поправка Torch', 'Ограничение влияния прогноза на балл'],
time_series_lstm_enabled: ['Загрузка Torch-файла включена', 'Техническое имя осталось историческим, но используется PyTorch LSTM/GRU'],
time_series_lstm_model_path: ['Файл Torch-модели', 'Путь к JSON-артефакту модели'],
time_series_probe_enabled: ['Probe-вход включён', 'Малый вход, если edge ниже полного порога, но всё ещё положительный'],
time_series_probe_min_edge_percent: ['Минимальный probe edge, %', 'Нижний edge для пробного входа'],
time_series_probe_min_probability_up: ['Минимальная probe P(up)', 'Вероятность роста для пробного входа'],
time_series_probe_size_multiplier: ['Размер probe-входа', 'Во сколько раз уменьшать обычный размер'],
time_series_rebound_fallback_enabled: ['Fallback отскока включён', 'Разрешает отскок без валидного Torch только при условиях rebound'],
time_series_model_artifact: ['Состояние Torch-модели', 'Что найдено в файле модели'],
stop_loss_percent: ['Стоп-лосс', 'Процент убытка, где позиция закрывается'],
take_profit_percent: ['Тейк-профит', 'Процент прибыли для фиксации'],
trailing_stop_percent: ['Trailing stop', 'Процентный трейлинг-стоп'],
min_hold_seconds: ['Минимальное удержание, сек', 'Защита от мгновенного выхода'],
entry_cooldown_seconds: ['Cooldown входа, сек', 'Пауза между покупками'],
max_daily_drawdown_usdt: ['Максимальная дневная просадка', 'Дневной лимит риска'],
min_cash_reserve_usdt: ['Минимальный резерв USDT', 'Сколько денег не трогать'],
taker_fee_rate: ['Комиссия taker', 'Комиссия биржи в расчётах'],
slippage_rate: ['Проскальзывание', 'Оценка ухудшения цены исполнения'],
live_ready: ['Live готов', 'Все защитные условия реальной торговли выполнены'],
live_order_max_usdt: ['Максимум live-ордера', 'Потолок одного реального ордера'],
};
const CHECK_LABELS = {
torch_model_ok: 'Torch-модель валидна',
quality_gate_ok: 'Gate качества модели пройден',
forecast_usable: 'Прогноз пригоден',
forecast_not_blocked: 'Прогноз не блокирует вход',
expected_edge_ok: 'Edge выше нужного порога',
probability_ok: 'P(up) выше порога',
skill_ok: 'Skill модели положительный',
confidence_ok: 'Confidence выше порога',
spread_ok: 'Спред не слишком широкий',
liquidity_ok: 'Ликвидность достаточная',
risk_size_ok: 'Kelly выделил размер покупки',
daily_trend_ok: 'Дневной тренд разрешает вход',
hourly_entry_ok: '1h вход подтверждён',
rsi_ok: 'RSI в рабочей зоне',
macd_cross_ok: 'MACD пересёк signal вверх',
price_above_ema_ok: 'Цена выше EMA',
};
const ACTION_LABELS = {
BUY: 'Покупка',
SELL: 'Продажа',
HOLD: 'Ждать',
};
initTabs();
wireControls();
refresh().catch(showError);
setInterval(() => refresh().catch(showError), 5000);
window.addEventListener('resize', debounce(() => requestAnimationFrame(drawCharts), 150));
function initTabs() {
document.getElementById('tabs').innerHTML = TABS
.map(([id, title]) => `<button type="button" class="tab ${id === state.activeTab ? 'active' : ''}" data-tab="${id}">${escapeHtml(title)}</button>`)
.join('');
document.querySelectorAll('[data-tab]').forEach(button => {
button.addEventListener('click', () => {
state.activeTab = button.dataset.tab;
initTabs();
render();
});
});
}
function wireControls() {
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 => {
await fetchJson('/api/config/fast-trading', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: event.target.checked }),
});
await refresh();
});
}
async function refresh() {
const [
health,
status,
markets,
trades,
signals,
events,
config,
analytics,
reconciliation,
backtest,
retrain,
] = await Promise.all([
fetchJson('/api/health'),
fetchJson('/api/status'),
fetchJson('/api/markets'),
fetchJson('/api/trades?limit=160'),
fetchJson('/api/signals?limit=220'),
fetchJson('/api/events?limit=160'),
fetchJson('/api/config'),
fetchJson('/api/analytics'),
fetchJson('/api/reconciliation'),
fetchJson('/api/backtest'),
fetchJson('/api/retrain'),
]);
state.data = { health, status, markets, trades, signals, events, config, analytics, reconciliation, backtest, retrain };
state.latestSignalsBySymbol = latestSignals(signals.items || []);
state.latestDiagnosticsBySymbol = latestDiagnostics(state.latestSignalsBySymbol);
updateHeader();
render();
}
async function fetchJson(url, options) {
const response = await fetch(url, options);
if (!response.ok) throw new Error(`${url}: HTTP ${response.status}`);
return response.json();
}
function updateHeader() {
const health = state.data.health || {};
const botStatus = state.data.status?.status || {};
const config = state.data.config || {};
const markets = state.data.markets || {};
const healthStatus = document.getElementById('healthStatus');
const ok = Boolean(health.ok);
healthStatus.textContent = ok ? (botStatus.running ? 'Бот работает' : 'Бот остановлен') : 'Ошибка API';
healthStatus.className = `status ${ok ? (botStatus.running ? 'ok' : 'warn') : 'bad'}`;
document.getElementById('fastToggle').checked = Boolean(config.fast_trading_enabled);
const symbols = (markets.symbols || config.symbols || []).join(', ');
document.getElementById('headline').textContent =
`Режим: ${modeLabel(health.mode || config.trading_mode)}. Пары: ${symbols || 'нет данных'}. Последнее обновление REST: ${formatDate(markets.last_rest_refresh_at) || 'нет'}.`;
}
function render() {
const root = document.getElementById('app');
const tab = state.activeTab;
if (tab === 'overview') root.innerHTML = overviewHtml();
if (tab === 'markets') root.innerHTML = marketsHtml();
if (tab === 'trades') root.innerHTML = tradesHtml();
if (tab === 'risk') root.innerHTML = riskHtml();
if (tab === 'model') root.innerHTML = modelHtml();
if (tab === 'logs') root.innerHTML = logsHtml();
if (tab === 'settings') root.innerHTML = settingsHtml();
if (tab === 'markets') requestAnimationFrame(drawCharts);
}
function overviewHtml() {
const status = state.data.status || {};
const account = status.account || {};
const botStatus = status.status || {};
const positions = status.positions || [];
const latestEquity = status.latest_equity || {};
const markets = state.data.markets || {};
const quality = markets.quality || {};
const artifact = state.data.config?.time_series_model_artifact || {};
const exposure = sum(positions.map(row => Number(row.market_value ?? row.notional_usdt ?? 0)));
const unrealized = sum(positions.map(row => Number(row.unrealized_pnl ?? 0)));
return `<div class="layout">
${section('Главное состояние', `
<div class="grid">
${metric('Equity', money(account.equity ?? latestEquity.equity), 'Текущая оценка депозита')}
${metric('Свободно USDT', money(account.cash ?? latestEquity.cash), 'Деньги без открытых позиций')}
${metric('Экспозиция', money(exposure || account.exposure || latestEquity.exposure), `${positions.length} открытых позиций`)}
${metric('Нереализованный PnL', signedMoney(unrealized), 'Прибыль/убыток по открытым позициям')}
${metric('Режим', modeLabel(botStatus.mode || state.data.health?.mode), botStatus.running ? 'Цикл принятия решений активен' : 'Цикл остановлен')}
${metric('Torch-модель', artifactLabel(artifact), `${artifact.symbol_count ?? 0} пар, ${artifact.feature_count ?? 0} признаков`)}
${metric('Качество данных', qualityLabel(quality.status), `Оценка: ${percent((quality.score || 0) * 100, 0)}`)}
${metric('WebSocket', markets.ws_connected ? 'Подключён' : 'Не подключён', `Последнее сообщение: ${formatDate(markets.last_ws_message_at) || 'нет'}`)}
</div>
`)}
${section('Открытые позиции', positionsTable(positions))}
${section('Последние решения', signalsTable((state.data.signals?.items || []).slice(0, 12)))}
</div>`;
}
function marketsHtml() {
const rows = state.data.markets?.markets || [];
if (!rows.length) return empty('Нет рыночных данных. Дождись первого обновления Bybit.');
return `<div class="markets">${rows.map(marketHtml).join('')}</div>`;
}
function marketHtml(market) {
const ticker = market.ticker || {};
const instrument = market.instrument || {};
const symbol = ticker.symbol || instrument.symbol || '';
const forecast = market.forecast || {};
const quality = market.quality || {};
const signal = state.latestSignalsBySymbol[symbol] || {};
const diag = state.latestDiagnosticsBySymbol[symbol] || {};
const sizing = diag.position_sizing || {};
const checks = diag.checks || {};
const latest = last(market.candles || []) || {};
const forecastSource = Object.keys(forecast).length ? forecast : (diag.forecast || {});
const edge = Number(diag.expected_return_percent ?? forecastSource.expected_return_percent ?? 0);
const statusClass = forecastStatusClass(forecastSource, checks);
return `<article class="market" data-symbol="${escapeAttr(symbol)}">
<div class="market-head">
<div>
<h2 class="market-title">${escapeHtml(symbol)}</h2>
<div class="subtitle">${escapeHtml(ACTION_LABELS[signal.action] || signal.action || 'Нет сигнала')} · ${escapeHtml(signal.reason || forecastSource.reason || 'ожидание данных')}</div>
</div>
<span class="status ${statusClass}">Edge ${signedPercent(edge, 3)}</span>
</div>
<div class="market-body">
<div>
<div class="chart-box">
<canvas class="exchange-chart" id="chart-${escapeAttr(symbol)}" data-symbol="${escapeAttr(symbol)}"></canvas>
<div class="chart-legend">
<span class="legend-item"><span class="legend-line"></span>EMA50</span>
<span class="legend-item"><span class="legend-line ema200"></span>EMA200</span>
<span class="legend-item"><span class="legend-line price"></span>Последняя цена</span>
<span class="legend-item">1h свечи · объём снизу</span>
</div>
</div>
<div class="note">Свечи: зелёная закрылась выше открытия, красная закрылась ниже. Шкала цены справа, время снизу.</div>
</div>
<div class="details">
${subsection('Рынок сейчас', kv([
['Цена', price(ticker.last_price ?? latest.close)],
['Изменение 24ч', signedPercent(ticker.change_24h, 2)],
['Спред', percent(ticker.spread_percent, 4)],
['Оборот 24ч', money(ticker.turnover_24h, 0)],
['RSI 14', number(latest.rsi_14, 2)],
['ATR 14', price(latest.atr_14)],
['EMA50', price(latest.ema_50)],
['EMA200', price(latest.ema_200)],
]))}
${subsection('Прогноз Torch', kv([
['Модель', modelLabel(forecastSource.model)],
['Горизонт', horizonLabel(forecastSource.horizon)],
['Ожидаемое движение', signedPercent(forecastSource.expected_return_percent, 4)],
['Edge для входа', signedPercent(edge, 4)],
['Вероятность роста P(up)', probability(forecastSource.probability_up)],
['Confidence сигнала', probability(signal.confidence ?? forecastSource.confidence_adjustment)],
['Skill модели', number(forecastSource.skill, 4)],
['Волатильность модели', percent(forecastSource.volatility_percent, 4)],
['Q10 / Q50 / Q90', `${signedPercent(forecastSource.quantile_10_percent, 3)} / ${signedPercent(forecastSource.quantile_50_percent, 3)} / ${signedPercent(forecastSource.quantile_90_percent, 3)}`],
['Gate качества', gateLabel(forecastSource.quality_gate_passed)],
['Причина', forecastSource.reason || 'нет текста'],
]))}
${subsection('Размер покупки по Kelly', kv([
['Метод', sizingMethod(sizing.method)],
['Можно купить сейчас', money(diag.position_notional_usdt ?? sizing.notional_usdt)],
['Цель Kelly по паре', money(sizing.kelly_target_notional_usdt)],
['Уже занято в паре', money(sizing.symbol_exposure_usdt)],
['Остаток Kelly', money(sizing.kelly_remaining_notional_usdt)],
['Множитель edge', number(sizing.torch_edge_multiplier, 4)],
['Множитель P(up)', number(sizing.torch_probability_multiplier, 4)],
['Множитель skill', number(sizing.torch_skill_multiplier, 4)],
]))}
</div>
<div class="wide">
${checksHtml(checks)}
${featureSnapshotHtml(forecastSource.feature_snapshot || [])}
${horizonForecastsHtml(forecastSource.horizon_forecasts || {})}
</div>
</div>
</article>`;
}
function tradesHtml() {
return `<div class="layout">
${section('Открытые позиции', positionsTable(state.data.status?.positions || []))}
${section('История сделок', tradesTable(state.data.trades?.items || []))}
</div>`;
}
function riskHtml() {
const config = state.data.config || {};
const markets = state.data.markets || {};
const positions = state.data.status?.positions || [];
const reconciliation = state.data.reconciliation || {};
const quality = markets.quality || {};
const marketRows = (markets.markets || []).map(market => {
const ticker = market.ticker || {};
const symbol = ticker.symbol || market.instrument?.symbol || '';
const diag = state.latestDiagnosticsBySymbol[symbol] || {};
const forecast = market.forecast || diag.forecast || {};
return {
'Пара': symbol,
'Качество данных': qualityLabel(market.quality?.status),
'Score': percent((market.quality?.score || 0) * 100, 0),
'Спред': percent(ticker.spread_percent, 4),
'Оборот 24ч': money(ticker.turnover_24h, 0),
'Edge': signedPercent(diag.expected_return_percent ?? forecast.expected_return_percent, 4),
'P(up)': probability(forecast.probability_up),
'Kelly остаток': money(diag.position_sizing?.kelly_remaining_notional_usdt),
};
});
return `<div class="layout">
${section('Лимиты риска', `
<div class="grid three">
${metric('Риск на сделку', percent(Number(config.risk_per_trade_percent || 0) * 100, 2), 'Сколько депозита можно потерять по стопу')}
${metric('Максимум в одной паре', money(config.max_symbol_exposure_usdt), 'Суммарно по нескольким покупкам')}
${metric('Максимум во всех позициях', money(config.max_total_exposure_usdt), 'Общий потолок экспозиции')}
${metric('Kelly', config.kelly_sizing_enabled ? 'Включён' : 'Выключен', `Доля: ${percent(Number(config.kelly_fraction || 0) * 100, 1)}`)}
${metric('Risk guard', config.risk_guard_enabled ? 'Включён' : 'Выключен', `PF минимум: ${number(config.risk_min_recent_profit_factor, 2)}`)}
${metric('Открыто позиций', String(positions.length), `Лимит: ${config.max_open_positions ?? 'нет данных'}`)}
</div>
`)}
${section('Качество рынка и edge по парам', tableFromObjects(marketRows))}
${section('Сверка с биржей', reconciliationHtml(reconciliation))}
${section('Общее качество данных', objectDetails(quality))}
</div>`;
}
function modelHtml() {
const backtest = state.data.backtest || {};
const retrain = state.data.retrain || {};
const artifact = state.data.config?.time_series_model_artifact || {};
return `<div class="layout">
${section('Артефакт Torch-модели', objectDetails(artifact))}
${section('Gate / калибровка порогов', objectDetails(backtest))}
${section('Guard переобучения', objectDetails(retrain))}
${section('Прогнозы по парам', modelPairsTable())}
</div>`;
}
function logsHtml() {
return `<div class="layout">
${section('Последние сигналы', signalsTable(state.data.signals?.items || []))}
${section('События бота', eventsTable(state.data.events?.items || []))}
</div>`;
}
function settingsHtml() {
const config = state.data.config || {};
const rows = Object.entries(config).map(([key, value]) => {
const [label, description] = CONFIG_LABELS[key] || [humanKey(key), 'Параметр конфигурации'];
return {
'Параметр': label,
'Значение': valueText(value),
'Что это значит': description,
'Технический ключ': key,
};
});
return `<div class="layout">
${section('Настройки на русском', tableFromObjects(rows, ['Параметр', 'Значение', 'Что это значит', 'Технический ключ']))}
</div>`;
}
function positionsTable(rows) {
const data = rows.map(row => ({
'Пара': row.symbol,
'Количество': number(row.qty, 8),
'Вход': price(row.entry_price),
'Текущая цена': price(row.mark_price),
'Размер': money(row.notional_usdt),
'Стоимость сейчас': money(row.market_value),
'PnL': signedMoney(row.unrealized_pnl),
'PnL %': signedPercent(row.unrealized_pnl_percent, 3),
'Стоп': price(row.stop_loss),
'Тейк': price(row.take_profit),
'Открыта': formatDate(row.opened_at),
'Причина входа': row.entry_reason || '',
}));
return tableFromObjects(data);
}
function tradesTable(rows) {
const data = rows.map(row => ({
'Время закрытия': formatDate(row.closed_at),
'Пара': row.symbol,
'Сторона': sideLabel(row.side),
'Количество': number(row.qty, 8),
'Вход': price(row.entry_price),
'Выход': price(row.exit_price),
'Gross PnL': signedMoney(row.gross_pnl),
'Комиссия': money(row.fee_usdt),
'Net PnL': signedMoney(row.net_pnl),
'Причина': row.reason,
}));
return tableFromObjects(data);
}
function signalsTable(rows) {
const data = rows.map(row => {
const diag = parseJson(row.diagnostics_json);
const forecast = diag.forecast || {};
return {
'Время': formatDate(row.created_at),
'Пара': row.symbol,
'Действие': ACTION_LABELS[row.action] || row.action,
'Confidence': probability(row.confidence),
'Edge': signedPercent(diag.expected_return_percent ?? forecast.expected_return_percent, 4),
'P(up)': probability(diag.probability_up ?? forecast.probability_up),
'Размер': money(diag.position_notional_usdt),
'Причина': row.reason,
};
});
return tableFromObjects(data);
}
function eventsTable(rows) {
const data = rows.map(row => ({
'Время': formatDate(row.created_at),
'Уровень': row.level,
'Сообщение': row.message,
}));
return tableFromObjects(data);
}
function checksHtml(checks) {
const rows = Object.entries(checks || {}).map(([key, value]) => ({
'Проверка': CHECK_LABELS[key] || humanKey(key),
'Статус': value ? 'Пройдена' : 'Блокирует',
'Технический ключ': key,
}));
if (!rows.length) return empty('Проверки входа ещё не рассчитаны.');
return `<div class="section wide">
<div class="section-head"><h3 class="section-title">Проверки входа</h3><div class="flags">${rows.map(row => `<span class="flag ${row['Статус'] === 'Пройдена' ? 'ok' : 'bad'}">${escapeHtml(row['Проверка'])}</span>`).join('')}</div></div>
<div class="section-body">${tableFromObjects(rows, ['Проверка', 'Статус', 'Технический ключ'])}</div>
</div>`;
}
function featureSnapshotHtml(items) {
const rows = (items || []).map(item => ({
'Группа': item.group || '',
'Параметр': item.label || item.name || '',
'Значение сейчас': item.raw_display ?? item.raw_value ?? '',
'Значение для модели': item.model_display ?? item.model_value ?? '',
'Что это значит': item.interpretation || item.description || '',
}));
if (!rows.length) return empty('Torch ещё не отдал список входных параметров для этой пары.');
return `<div class="section wide feature-table">
<div class="section-head"><h3 class="section-title">Что видит нейросеть перед прогнозом</h3><span class="status">${rows.length} параметров</span></div>
<div class="section-body">${tableFromObjects(rows, ['Группа', 'Параметр', 'Значение сейчас', 'Значение для модели', 'Что это значит'])}</div>
</div>`;
}
function horizonForecastsHtml(items) {
const rows = Object.entries(items || {}).map(([horizon, row]) => ({
'Горизонт': `${horizon} свеч.`,
'Ожидаемое движение': signedPercent(row.expected_return_percent, 4),
'P(up)': probability(row.probability_up),
'Q10': signedPercent(row.quantile_10_percent, 4),
'Q50': signedPercent(row.quantile_50_percent, 4),
'Q90': signedPercent(row.quantile_90_percent, 4),
}));
if (!rows.length) return '';
return `<div class="section wide">
<div class="section-head"><h3 class="section-title">Прогноз по нескольким горизонтам</h3></div>
<div class="section-body">${tableFromObjects(rows)}</div>
</div>`;
}
function modelPairsTable() {
const rows = (state.data.markets?.markets || []).map(market => {
const symbol = market.ticker?.symbol || market.instrument?.symbol || '';
const diag = state.latestDiagnosticsBySymbol[symbol] || {};
const forecast = market.forecast || diag.forecast || {};
return {
'Пара': symbol,
'Модель': modelLabel(forecast.model),
'Горизонт': horizonLabel(forecast.horizon),
'Edge': signedPercent(diag.expected_return_percent ?? forecast.expected_return_percent, 4),
'P(up)': probability(forecast.probability_up),
'Skill': number(forecast.skill, 4),
'Confidence': probability(state.latestSignalsBySymbol[symbol]?.confidence),
'Gate': gateLabel(forecast.quality_gate_passed),
'Причина': forecast.reason || state.latestSignalsBySymbol[symbol]?.reason || '',
};
});
return tableFromObjects(rows);
}
function reconciliationHtml(data) {
if (!data || Object.keys(data).length === 0) return empty('Нет данных сверки.');
const rows = Array.isArray(data.discrepancies) ? data.discrepancies.map(row => ({
'Серьёзность': row.severity || '',
'Код': row.code || '',
'Монета': row.coin || '',
'Описание': row.message || valueText(row),
})) : [];
if (rows.length) return tableFromObjects(rows);
return objectDetails(data);
}
function section(title, body) {
return `<section class="section">
<div class="section-head"><h2 class="section-title">${escapeHtml(title)}</h2></div>
<div class="section-body">${body}</div>
</section>`;
}
function subsection(title, body) {
return `<div class="subsection"><h3>${escapeHtml(title)}</h3>${body}</div>`;
}
function metric(name, value, hint) {
return `<div class="metric">
<div class="name">${escapeHtml(name)}</div>
<div class="value">${escapeHtml(value || 'нет данных')}</div>
<div class="hint">${escapeHtml(hint || '')}</div>
</div>`;
}
function kv(rows) {
return rows.map(([key, value]) => `<div class="kv"><div class="key">${escapeHtml(key)}</div><div class="val">${escapeHtml(value || 'нет данных')}</div></div>`).join('');
}
function tableFromObjects(rows, columns) {
if (!rows || !rows.length) return empty('Нет данных для таблицы.');
const cols = columns || Object.keys(rows[0]);
return `<div class="table-wrap"><table>
<thead><tr>${cols.map(col => `<th>${escapeHtml(col)}</th>`).join('')}</tr></thead>
<tbody>${rows.map(row => `<tr>${cols.map(col => `<td class="${isNumericText(row[col]) ? 'num mono' : ''}">${escapeHtml(row[col] ?? '')}</td>`).join('')}</tr>`).join('')}</tbody>
</table></div>`;
}
function objectDetails(value) {
if (!value || Object.keys(value).length === 0) return empty('Нет данных.');
const rows = flattenObject(value).map(([key, val]) => ({
'Параметр': humanKey(key),
'Значение': valueText(val),
}));
return tableFromObjects(rows, ['Параметр', 'Значение']);
}
function flattenObject(value, prefix = '') {
const rows = [];
Object.entries(value || {}).forEach(([key, val]) => {
const full = prefix ? `${prefix}.${key}` : key;
if (val && typeof val === 'object' && !Array.isArray(val)) {
rows.push(...flattenObject(val, full));
} else {
rows.push([full, val]);
}
});
return rows;
}
function drawCharts() {
(state.data.markets?.markets || []).forEach(market => {
const symbol = market.ticker?.symbol || market.instrument?.symbol || '';
const canvas = document.querySelector(`canvas[data-symbol="${cssEscape(symbol)}"]`);
if (canvas) drawExchangeChart(canvas, market.candles || []);
});
}
function drawExchangeChart(canvas, candles) {
const ctx = canvas.getContext('2d');
const rect = canvas.getBoundingClientRect();
const width = Math.max(320, Math.floor(rect.width || 800));
const height = Math.max(260, Math.floor(rect.height || 330));
const dpr = window.devicePixelRatio || 1;
canvas.width = Math.floor(width * dpr);
canvas.height = Math.floor(height * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = '#0f141b';
ctx.fillRect(0, 0, width, height);
const rows = (candles || []).filter(row => Number(row.high) > 0 && Number(row.low) > 0).slice(-90);
if (rows.length < 2) {
ctx.fillStyle = '#94a3b8';
ctx.font = '13px Segoe UI, Arial';
ctx.fillText('Нет свечей для графика', 16, 30);
return;
}
const padLeft = 10;
const padRight = 68;
const padTop = 14;
const padBottom = 25;
const volumeHeight = Math.max(48, height * 0.18);
const chartBottom = height - padBottom - volumeHeight - 10;
const chartHeight = Math.max(120, chartBottom - padTop);
const plotWidth = width - padLeft - padRight;
const values = rows.flatMap(row => [Number(row.high), Number(row.low), Number(row.ema_50), Number(row.ema_200)]).filter(Number.isFinite);
let min = Math.min(...values);
let max = Math.max(...values);
const range = Math.max(max - min, max * 0.0001, 1e-9);
min -= range * 0.08;
max += range * 0.08;
const y = value => padTop + ((max - value) / (max - min)) * chartHeight;
const x = index => padLeft + index * (plotWidth / rows.length) + plotWidth / rows.length / 2;
const step = plotWidth / rows.length;
const bodyWidth = clamp(step * 0.62, 3, 11);
drawGrid(ctx, width, padLeft, padRight, padTop, chartHeight, min, max);
drawVolumes(ctx, rows, padLeft, plotWidth, chartBottom + 12, volumeHeight - 6);
rows.forEach((row, index) => drawCandle(ctx, row, x(index), bodyWidth, y));
drawLine(ctx, rows, 'ema_50', x, y, '#93c5fd');
drawLine(ctx, rows, 'ema_200', x, y, '#f59e0b');
drawLastPrice(ctx, rows[rows.length - 1], width, padLeft, padRight, y);
drawTimeAxis(ctx, rows, padLeft, plotWidth, height - 7);
}
function drawGrid(ctx, width, padLeft, padRight, padTop, chartHeight, min, max) {
ctx.strokeStyle = '#263241';
ctx.fillStyle = '#9aa7b7';
ctx.lineWidth = 1;
ctx.font = '11px Segoe UI, Arial';
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
for (let i = 0; i <= 5; i += 1) {
const ratio = i / 5;
const yy = padTop + chartHeight * ratio;
const value = max - (max - min) * ratio;
ctx.beginPath();
ctx.moveTo(padLeft, yy);
ctx.lineTo(width - padRight + 4, yy);
ctx.stroke();
ctx.fillText(compactPrice(value), width - 6, yy);
}
}
function drawVolumes(ctx, rows, padLeft, plotWidth, top, height) {
const maxVolume = Math.max(...rows.map(row => Number(row.volume || 0)), 1);
const step = plotWidth / rows.length;
const width = clamp(step * 0.55, 2, 8);
rows.forEach((row, index) => {
const volume = Number(row.volume || 0);
const barHeight = Math.max(1, (volume / maxVolume) * height);
const xx = padLeft + index * step + step / 2 - width / 2;
ctx.fillStyle = Number(row.close) >= Number(row.open) ? 'rgba(34, 197, 94, .35)' : 'rgba(239, 68, 68, .35)';
ctx.fillRect(xx, top + height - barHeight, width, barHeight);
});
}
function drawCandle(ctx, row, xx, bodyWidth, y) {
const open = Number(row.open);
const high = Number(row.high);
const low = Number(row.low);
const close = Number(row.close);
const up = close >= open;
const color = up ? '#22c55e' : '#ef4444';
const top = y(Math.max(open, close));
const bottom = y(Math.min(open, close));
const bodyHeight = Math.max(1.2, bottom - top);
ctx.strokeStyle = color;
ctx.fillStyle = color;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(xx, y(high));
ctx.lineTo(xx, y(low));
ctx.stroke();
ctx.fillRect(xx - bodyWidth / 2, top, bodyWidth, bodyHeight);
}
function drawLine(ctx, rows, key, x, y, color) {
ctx.strokeStyle = color;
ctx.lineWidth = 1.4;
ctx.beginPath();
let started = false;
rows.forEach((row, index) => {
const value = Number(row[key]);
if (!Number.isFinite(value) || value <= 0) return;
if (!started) {
ctx.moveTo(x(index), y(value));
started = true;
} else {
ctx.lineTo(x(index), y(value));
}
});
if (started) ctx.stroke();
}
function drawLastPrice(ctx, row, width, padLeft, padRight, y) {
const close = Number(row.close);
if (!Number.isFinite(close)) return;
const yy = y(close);
ctx.setLineDash([5, 4]);
ctx.strokeStyle = '#e5e7eb';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(padLeft, yy);
ctx.lineTo(width - padRight + 6, yy);
ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = '#e5e7eb';
ctx.font = '11px Segoe UI, Arial';
ctx.textAlign = 'right';
ctx.textBaseline = 'bottom';
ctx.fillText(compactPrice(close), width - 6, yy - 2);
}
function drawTimeAxis(ctx, rows, padLeft, plotWidth, baseline) {
ctx.fillStyle = '#9aa7b7';
ctx.font = '11px Segoe UI, Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'alphabetic';
const ticks = Math.min(5, rows.length);
for (let i = 0; i < ticks; i += 1) {
const index = Math.round((rows.length - 1) * (i / Math.max(1, ticks - 1)));
const xx = padLeft + index * (plotWidth / rows.length) + plotWidth / rows.length / 2;
ctx.fillText(shortTime(rows[index].timestamp), xx, baseline);
}
}
function latestSignals(rows) {
const output = {};
rows.forEach(row => {
if (row.symbol && !output[row.symbol]) output[row.symbol] = row;
});
return output;
}
function latestDiagnostics(signals) {
const output = {};
Object.entries(signals || {}).forEach(([symbol, row]) => {
output[symbol] = parseJson(row.diagnostics_json);
});
return output;
}
function parseJson(text) {
if (!text) return {};
try { return JSON.parse(text); } catch { return {}; }
}
function showError(error) {
document.getElementById('headline').textContent = `Ошибка загрузки панели: ${error.message}`;
const status = document.getElementById('healthStatus');
status.textContent = 'Ошибка';
status.className = 'status bad';
}
function money(value, digits = 2) {
const n = Number(value);
if (!Number.isFinite(n)) return '';
return `${n.toLocaleString('ru-RU', { minimumFractionDigits: digits, maximumFractionDigits: digits })} USDT`;
}
function signedMoney(value) {
const n = Number(value);
if (!Number.isFinite(n)) return '';
return `${n >= 0 ? '+' : ''}${money(n)}`;
}
function price(value) {
const n = Number(value);
if (!Number.isFinite(n) || n === 0) return '';
const digits = n >= 100 ? 2 : n >= 1 ? 4 : 8;
return n.toLocaleString('ru-RU', { minimumFractionDigits: digits, maximumFractionDigits: digits });
}
function compactPrice(value) {
const n = Number(value);
if (!Number.isFinite(n)) return '';
if (Math.abs(n) >= 1000) return n.toLocaleString('ru-RU', { maximumFractionDigits: 0 });
if (Math.abs(n) >= 1) return n.toLocaleString('ru-RU', { maximumFractionDigits: 3 });
return n.toLocaleString('ru-RU', { maximumFractionDigits: 8 });
}
function number(value, digits = 2) {
const n = Number(value);
if (!Number.isFinite(n)) return '';
return n.toLocaleString('ru-RU', { minimumFractionDigits: digits, maximumFractionDigits: digits });
}
function percent(value, digits = 2) {
const n = Number(value);
if (!Number.isFinite(n)) return '';
return `${n.toLocaleString('ru-RU', { minimumFractionDigits: digits, maximumFractionDigits: digits })}%`;
}
function signedPercent(value, digits = 2) {
const n = Number(value);
if (!Number.isFinite(n)) return '';
return `${n >= 0 ? '+' : ''}${percent(n, digits)}`;
}
function probability(value) {
const n = Number(value);
if (!Number.isFinite(n)) return '';
return `${(n * 100).toLocaleString('ru-RU', { minimumFractionDigits: 1, maximumFractionDigits: 1 })}%`;
}
function formatDate(value) {
if (!value) return '';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return String(value);
return date.toLocaleString('ru-RU', { hour12: false });
}
function shortTime(value) {
const raw = Number(value);
const date = raw > 100000000000 ? new Date(raw) : new Date(raw * 1000);
if (Number.isNaN(date.getTime())) return '';
return date.toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false });
}
function valueText(value) {
if (Array.isArray(value)) return value.join(', ');
if (value && typeof value === 'object') return JSON.stringify(value, null, 2);
if (typeof value === 'boolean') return value ? 'да' : 'нет';
if (value === null || value === undefined || value === '') return '';
return String(value);
}
function modelLabel(value) {
if (value === 'torch_lstm') return 'PyTorch LSTM';
if (value === 'torch_gru') return 'PyTorch GRU';
if (value === 'none') return 'Нет валидной Torch-модели';
return value || 'нет данных';
}
function sizingMethod(value) {
if (value === 'torch_forecast_fractional_kelly') return 'Torch + дробный Kelly';
if (value === 'torch_forecast_risk') return 'Torch + риск по стопу';
if (value === 'torch_forecast_rebound') return 'Torch + отскок';
if (value === 'torch_forecast_rebound_fallback') return 'Отскок без валидного Torch';
return value || 'нет данных';
}
function horizonLabel(value) {
const n = Number(value);
if (!Number.isFinite(n) || n <= 0) return 'нет данных';
return `${n} свеч. вперёд`;
}
function modeLabel(value) {
if (value === 'paper') return 'paper, симуляция';
if (value === 'live') return 'live, реальные ордера';
return value || 'нет данных';
}
function sideLabel(value) {
if (value === 'BUY') return 'Покупка';
if (value === 'SELL') return 'Продажа';
return value || '';
}
function qualityLabel(value) {
if (value === 'ok') return 'норма';
if (value === 'warn') return 'предупреждение';
if (value === 'error') return 'ошибка';
if (!value) return 'нет данных';
return value;
}
function gateLabel(value) {
if (value === true) return 'пройден';
if (value === false) return 'не пройден';
return 'нет данных';
}
function artifactLabel(artifact) {
if (!artifact) return 'нет данных';
if (artifact.available) return artifact.label || 'PyTorch LSTM/GRU';
return artifact.label || 'нет валидной модели';
}
function forecastStatusClass(forecast, checks) {
if (checks && Object.keys(checks).length && Object.values(checks).every(Boolean)) return 'ok';
if (forecast?.block_entry || forecast?.quality_gate_passed === false) return 'bad';
const edge = Number(forecast?.expected_return_percent);
if (Number.isFinite(edge) && edge > 0) return 'warn';
return 'warn';
}
function humanKey(key) {
return String(key || '')
.replaceAll('_', ' ')
.replaceAll('.', ' / ')
.replace(/\b\w/g, char => char.toUpperCase());
}
function isNumericText(value) {
return typeof value === 'string' && /^[+\-]?\d/.test(value.trim());
}
function sum(values) {
return values.reduce((total, value) => total + (Number.isFinite(value) ? value : 0), 0);
}
function last(rows) {
return rows && rows.length ? rows[rows.length - 1] : null;
}
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}
function empty(text) {
return `<div class="empty">${escapeHtml(text)}</div>`;
}
function cssEscape(value) {
return String(value || '').replace(/["\\]/g, '\\$&');
}
function escapeAttr(value) {
return String(value || '').replace(/[^A-Za-z0-9_-]/g, '');
}
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>"']/g, char => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
}[char]));
}
function debounce(fn, wait) {
let timer = null;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), wait);
};
}
</script>
</body>
</html>
"""