feat: add TradeBot web control panel

This commit is contained in:
Курнат Андрей
2026-07-19 22:00:23 +03:00
parent 991b77351c
commit be5c9d482a
6 changed files with 1303 additions and 12 deletions
+7
View File
@@ -1,5 +1,11 @@
# Crypto Spot TradeBot
Веб-панель управления доступна на корневом адресе сервиса: локально
`http://127.0.0.1:8787/`, в production — `https://tb.kusoft.xyz/`. Панель показывает
готовность торгового контура, капитал, позиции, рынки, сигналы, сделки, события и
состояние модели; из неё можно запускать и останавливать цикл и переключать быстрый
режим. Приватные данные и управляющие действия используют ту же авторизацию, что и API.
Spot-бот для демо-торговли криптовалютой на реальных данных Bybit. По умолчанию работает только в `paper`-режиме со стартовым балансом `100 USDT`; live-режим заблокирован до явного включения через env-переменные.
## Что реализовано
@@ -254,6 +260,7 @@ Live-исполнение ведет журнал order intent до отправ
- `GET /api/health` — healthcheck.
- `GET /api/status` — статус бота, account snapshot, позиции.
- `GET /api/dashboard/snapshot` — компактный защищённый snapshot для веб-панели.
- `GET /api/markets` — пары, ticker, свечи, инструменты.
- `GET /api/training/market-observations?symbol=BTCUSDT&after_id=0&limit=5000` — защищённая training-token выгрузка L1-наблюдений.
- `GET /api/training/market-observations/manifest` — training-token manifest для инкрементальной синхронизации forward L1-данных.
+95 -8
View File
@@ -4,10 +4,13 @@ import asyncio
import json
import logging
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from fastapi import Depends, FastAPI, HTTPException, Response
from fastapi.responses import JSONResponse, PlainTextResponse
from fastapi import Depends, FastAPI, HTTPException, Request, Response
from fastapi.responses import FileResponse, JSONResponse, PlainTextResponse
from fastapi.staticfiles import StaticFiles
from crypto_spot_bot.analytics import analytics_snapshot
from crypto_spot_bot.auth import ApiAuthorizer
@@ -27,7 +30,8 @@ from crypto_spot_bot.time_series import TimeSeriesForecaster
from crypto_spot_bot.training_coordination import TrainingCoordinator
WEB_UI_REMOVED_MESSAGE = "Web UI removed. Use the Android TradeBot AI app and /api/* endpoints."
WEB_ROOT = Path(__file__).with_name("web")
WEB_INDEX = WEB_ROOT / "index.html"
logger = logging.getLogger(__name__)
@@ -82,10 +86,27 @@ def create_app(settings: Settings | None = None) -> FastAPI:
app.state.bot = bot
app.state.market = market
app.state.training = training
app.mount("/assets", StaticFiles(directory=WEB_ROOT), name="dashboard-assets")
@app.get("/", response_class=PlainTextResponse, status_code=410)
async def index() -> str:
return WEB_UI_REMOVED_MESSAGE
@app.middleware("http")
async def security_headers(request: Request, call_next) -> Response:
response = await call_next(request)
response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault("Referrer-Policy", "no-referrer")
response.headers.setdefault("X-Frame-Options", "DENY")
response.headers.setdefault(
"Content-Security-Policy",
"default-src 'self'; style-src 'self'; script-src 'self'; "
"img-src 'self' data:; connect-src 'self'; font-src 'self'; "
"object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'",
)
if request.url.path == "/":
response.headers.setdefault("Cache-Control", "no-store")
return response
@app.get("/", response_class=FileResponse)
async def index() -> FileResponse:
return FileResponse(WEB_INDEX, media_type="text/html")
@app.get("/api/health")
async def health() -> dict[str, Any]:
@@ -304,8 +325,6 @@ def create_app(settings: Settings | None = None) -> FastAPI:
"status": bot.status().as_dict(),
"account": bot.account_snapshot(),
"positions": bot.positions_snapshot(),
"learning": bot.learning_snapshot(),
"latest_equity": storage.latest_equity(mode=settings.trading_mode),
"readiness": bot.readiness_snapshot(),
},
"markets": market.snapshot(),
@@ -320,6 +339,43 @@ def create_app(settings: Settings | None = None) -> FastAPI:
"backtest": _runtime_json(settings, "torch_threshold_calibration.json"),
}
@app.get("/api/dashboard/snapshot")
async def dashboard_snapshot(_: None = Depends(authorizer.require)) -> dict[str, Any]:
market_data = market.snapshot()
retrain_data = _runtime_json(settings, "torch_retrain_guard.json")
retrain_data["coordination"] = training.status()
retrain_data["shadow"] = shadow_gate_snapshot(
storage,
shadow_forecaster.artifact_sha256(),
)
return {
"generated_at": datetime.now(timezone.utc).isoformat(),
"health": {
"ok": True,
"running": bot.running,
"mode": settings.trading_mode,
"version": __version__,
},
"status": {
"status": bot.status().as_dict(),
"account": bot.account_snapshot(),
"positions": bot.positions_snapshot(),
"learning": bot.learning_snapshot(),
"latest_equity": storage.latest_equity(mode=settings.trading_mode),
"readiness": bot.readiness_snapshot(),
},
"markets": _compact_markets(market_data),
"signals": {"items": storage.recent_signals(16)},
"trades": {
"items": storage.recent_trades(16, mode=settings.trading_mode),
"closed_items": storage.closed_trades(16, mode=settings.trading_mode),
"closed_summary": storage.closed_trade_summary(mode=settings.trading_mode),
},
"events": {"items": storage.recent_events(20)},
"retrain": retrain_data,
"config": _safe_config(settings),
}
@app.post("/api/config/fast-trading")
async def set_fast_trading(
payload: dict[str, Any],
@@ -383,6 +439,37 @@ def create_app(settings: Settings | None = None) -> FastAPI:
return app
def _compact_markets(snapshot: dict[str, Any]) -> dict[str, Any]:
markets: list[dict[str, Any]] = []
for market in snapshot.get("markets", []):
candles = market.get("candles") or []
markets.append(
{
"ticker": market.get("ticker"),
"sparkline": [
{
"timestamp": candle.get("timestamp"),
"close": candle.get("close"),
}
for candle in candles[-48:]
],
"forecast": market.get("forecast"),
"quality": market.get("quality"),
}
)
return {
"symbols": snapshot.get("symbols", []),
"ws_connected": snapshot.get("ws_connected", False),
"rest_error_count": snapshot.get("rest_error_count", 0),
"last_rest_error": snapshot.get("last_rest_error", ""),
"last_rest_refresh_at": snapshot.get("last_rest_refresh_at"),
"last_ws_message_at": snapshot.get("last_ws_message_at"),
"observation_collector": snapshot.get("observation_collector", {}),
"quality": snapshot.get("quality", {}),
"markets": markets,
}
def _limit(value: int) -> int:
return max(1, min(int(value), 500))
+409
View File
@@ -0,0 +1,409 @@
:root {
--bg: #090b0f;
--surface: #101319;
--surface-2: #151920;
--surface-3: #1b2029;
--line: #242a34;
--line-soft: #1b2028;
--text: #f4f6f8;
--muted: #8d96a5;
--dim: #626b79;
--green: #26d99a;
--green-soft: #10271f;
--red: #ff6275;
--red-soft: #2b151b;
--amber: #f3b34c;
--amber-soft: #2a2113;
--blue: #7891ff;
--sidebar: 224px;
--radius: 10px;
font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: var(--text);
background: var(--bg);
font-synthesis: none;
}
* { box-sizing: border-box; }
html { min-width: 320px; background: var(--bg); }
body {
min-height: 100vh;
margin: 0;
background: var(--bg);
color: var(--text);
-webkit-font-smoothing: antialiased;
}
button, input { font: inherit; }
button { color: inherit; }
button:focus-visible, input:focus-visible, a:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; }
a { color: inherit; text-decoration: none; }
.app-shell { min-height: 100vh; }
.sidebar {
position: fixed;
inset: 0 auto 0 0;
z-index: 20;
display: flex;
width: var(--sidebar);
flex-direction: column;
border-right: 1px solid var(--line-soft);
background: #0c0f14;
}
.brand {
display: flex;
min-height: 86px;
align-items: center;
gap: 12px;
padding: 0 24px;
border-bottom: 1px solid var(--line-soft);
}
.brand-mark, .dialog-icon {
display: grid;
width: 35px;
height: 35px;
place-items: center;
border: 1px solid #2d8c6d;
border-radius: 8px;
background: #10231d;
color: var(--green);
font-weight: 850;
letter-spacing: -.04em;
}
.brand strong { display: block; font-size: 15px; letter-spacing: .01em; }
.brand small { display: block; margin-top: 3px; color: var(--dim); font-size: 10px; font-weight: 700; letter-spacing: .16em; text-transform: uppercase; }
.nav-list { display: grid; gap: 5px; padding: 22px 14px; }
.nav-item {
display: flex;
width: 100%;
align-items: center;
gap: 13px;
padding: 11px 12px;
border: 1px solid transparent;
border-radius: 7px;
background: transparent;
color: var(--muted);
cursor: pointer;
font-size: 13px;
font-weight: 650;
text-align: left;
transition: background .15s ease, color .15s ease, border-color .15s ease;
}
.nav-item:hover { background: var(--surface); color: var(--text); }
.nav-item.is-active { border-color: #25352f; background: #111b18; color: var(--green); }
.nav-item svg { width: 18px; height: 18px; flex: 0 0 auto; fill: currentColor; }
.sidebar-foot { margin-top: auto; padding: 20px 18px 22px; border-top: 1px solid var(--line-soft); }
.connection-mini { display: flex; align-items: center; gap: 10px; }
.connection-mini strong { display: block; font-size: 11px; font-weight: 700; }
.connection-mini small { display: block; margin-top: 3px; color: var(--dim); font-size: 10px; }
.live-dot { width: 8px; height: 8px; flex: 0 0 auto; border-radius: 50%; background: var(--amber); box-shadow: 0 0 0 4px rgba(243,179,76,.09); }
.live-dot.is-online { background: var(--green); box-shadow: 0 0 0 4px rgba(38,217,154,.09); }
.live-dot.is-offline { background: var(--red); box-shadow: 0 0 0 4px rgba(255,98,117,.09); }
.version-line { margin-top: 17px; color: var(--dim); font: 10px ui-monospace, SFMono-Regular, Consolas, monospace; }
.main { min-height: 100vh; margin-left: var(--sidebar); }
.topbar {
position: sticky;
top: 0;
z-index: 10;
display: flex;
min-height: 86px;
align-items: center;
justify-content: space-between;
padding: 0 32px;
border-bottom: 1px solid var(--line-soft);
background: rgba(9,11,15,.94);
backdrop-filter: blur(14px);
}
.eyebrow { margin: 0 0 7px; color: var(--dim); font-size: 9px; font-weight: 800; letter-spacing: .17em; text-transform: uppercase; }
.topbar h1 { margin: 0; font-size: 21px; font-weight: 720; letter-spacing: -.02em; }
.topbar-actions { display: flex; align-items: center; gap: 12px; }
.sync-label { color: var(--dim); font-size: 11px; }
.badge {
display: inline-flex;
min-height: 24px;
align-items: center;
justify-content: center;
padding: 0 9px;
border: 1px solid var(--line);
border-radius: 5px;
background: var(--surface-2);
color: var(--muted);
font-size: 9px;
font-weight: 800;
letter-spacing: .09em;
text-transform: uppercase;
}
.badge-mode { color: var(--amber); }
.badge.is-good { border-color: #245c49; background: var(--green-soft); color: var(--green); }
.badge.is-warn { border-color: #5e4927; background: var(--amber-soft); color: var(--amber); }
.badge.is-bad { border-color: #63313a; background: var(--red-soft); color: var(--red); }
.icon-button {
display: grid;
width: 34px;
height: 34px;
place-items: center;
border: 1px solid var(--line);
border-radius: 7px;
background: var(--surface);
cursor: pointer;
}
.icon-button:hover { background: var(--surface-2); }
.icon-button svg { width: 16px; height: 16px; fill: var(--muted); }
.icon-button.is-spinning svg { animation: spin .7s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.page { display: none; max-width: 1480px; margin: 0 auto; padding: 26px 32px 48px; }
.page.is-active { display: block; }
.offline-banner {
margin: 18px 32px 0;
padding: 11px 14px;
border: 1px solid #63313a;
border-radius: 7px;
background: var(--red-soft);
color: var(--red);
font-size: 12px;
}
.offline-banner span { margin-left: 5px; color: #dba3aa; }
.card { border: 1px solid var(--line-soft); border-radius: var(--radius); background: var(--surface); }
.hero {
position: relative;
display: grid;
min-height: 250px;
grid-template-columns: minmax(0, 1.5fr) minmax(300px, .7fr);
overflow: hidden;
border-color: #25322e;
background-color: #0f1514;
background-image: linear-gradient(rgba(38,217,154,.035) 1px, transparent 1px), linear-gradient(90deg, rgba(38,217,154,.035) 1px, transparent 1px);
background-size: 28px 28px;
}
.hero::after { position: absolute; inset: auto 0 0; height: 2px; background: var(--green); content: ""; opacity: .7; }
.hero-copy { align-self: center; padding: 35px 38px; }
.status-kicker { display: flex; align-items: center; gap: 9px; color: var(--green); font-size: 11px; font-weight: 750; letter-spacing: .06em; text-transform: uppercase; }
.status-orb { width: 8px; height: 8px; border-radius: 50%; background: var(--amber); }
.status-orb.is-good { background: var(--green); box-shadow: 0 0 14px rgba(38,217,154,.5); }
.status-orb.is-bad { background: var(--red); }
.hero h2 { max-width: 670px; margin: 17px 0 10px; font-size: clamp(27px, 3vw, 43px); font-weight: 760; letter-spacing: -.045em; line-height: 1.05; }
.hero-copy > p { max-width: 650px; margin: 0; color: var(--muted); font-size: 13px; line-height: 1.65; }
.hero-actions { display: flex; gap: 9px; margin-top: 25px; }
.button {
min-height: 37px;
padding: 0 16px;
border: 1px solid var(--line);
border-radius: 7px;
background: var(--surface-2);
cursor: pointer;
font-size: 11px;
font-weight: 750;
transition: transform .12s ease, filter .12s ease;
}
.button:hover:not(:disabled) { filter: brightness(1.1); transform: translateY(-1px); }
.button:disabled { cursor: not-allowed; opacity: .42; }
.button-primary { border-color: #287258; background: #143c30; color: var(--green); }
.button-danger { border-color: #66333b; background: #32181e; color: var(--red); }
.button-secondary { background: var(--surface-3); color: var(--muted); }
.button-wide { width: 100%; }
.hero-telemetry { display: grid; align-content: center; gap: 0; padding: 25px 34px; border-left: 1px solid rgba(38,217,154,.12); background: rgba(5,10,9,.62); }
.hero-telemetry div { display: flex; align-items: baseline; justify-content: space-between; gap: 15px; padding: 18px 0; border-bottom: 1px solid #1c2925; }
.hero-telemetry div:last-child { border-bottom: 0; }
.hero-telemetry span { color: var(--dim); font-size: 10px; letter-spacing: .05em; text-transform: uppercase; }
.hero-telemetry strong { font: 650 13px ui-monospace, SFMono-Regular, Consolas, monospace; }
.metric-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-top: 12px; }
.metric { min-height: 126px; padding: 21px 22px; }
.metric > span { color: var(--muted); font-size: 10px; font-weight: 650; }
.metric strong { display: block; margin-top: 15px; font: 700 clamp(22px, 2.5vw, 30px) ui-monospace, SFMono-Regular, Consolas, monospace; letter-spacing: -.04em; }
.metric small { display: block; margin-top: 8px; color: var(--dim); font-size: 10px; }
.positive { color: var(--green) !important; }
.negative { color: var(--red) !important; }
.warning { color: var(--amber) !important; }
.content-grid { display: grid; grid-template-columns: minmax(0, 2fr) minmax(280px, 1fr); gap: 12px; margin-top: 12px; }
.content-grid > .card, .system-grid > .card { min-width: 0; padding: 22px; }
.span-2 { grid-column: 1; }
.card-head { display: flex; min-height: 40px; align-items: flex-start; justify-content: space-between; gap: 15px; margin-bottom: 18px; }
.card-head h2 { margin: 0; font-size: 16px; font-weight: 700; letter-spacing: -.02em; }
.subtext { margin: 8px 0 0; color: var(--muted); font-size: 11px; line-height: 1.55; }
.text-button { padding: 3px 0; border: 0; background: transparent; color: var(--green); cursor: pointer; font-size: 10px; font-weight: 700; }
.table-wrap { width: 100%; overflow-x: auto; }
table { width: 100%; border-collapse: collapse; }
th { padding: 9px 12px; border-bottom: 1px solid var(--line); color: var(--dim); font-size: 9px; font-weight: 750; letter-spacing: .08em; text-align: right; text-transform: uppercase; white-space: nowrap; }
th:first-child, td:first-child { padding-left: 0; text-align: left; }
th:last-child, td:last-child { padding-right: 0; }
td { height: 54px; padding: 9px 12px; border-bottom: 1px solid var(--line-soft); color: #cad0d8; font: 11px ui-monospace, SFMono-Regular, Consolas, monospace; text-align: right; white-space: nowrap; }
tbody tr:last-child td { border-bottom: 0; }
tbody tr:hover td { background: rgba(255,255,255,.012); }
.symbol-cell { color: var(--text); font: 750 12px Inter, ui-sans-serif, sans-serif; }
.symbol-cell small { display: block; margin-top: 4px; color: var(--dim); font: 9px ui-monospace, SFMono-Regular, Consolas, monospace; }
.sparkline { display: block; width: 88px; height: 28px; margin-left: auto; overflow: visible; }
.sparkline polyline { fill: none; stroke: var(--green); stroke-width: 1.5; vector-effect: non-scaling-stroke; }
.sparkline.is-down polyline { stroke: var(--red); }
.row-state { display: inline-flex; align-items: center; gap: 6px; font: 700 9px Inter, ui-sans-serif, sans-serif; letter-spacing: .04em; text-transform: uppercase; }
.row-state::before { width: 6px; height: 6px; border-radius: 50%; background: currentColor; content: ""; }
.empty { height: 120px; color: var(--dim); font: 11px Inter, ui-sans-serif, sans-serif; text-align: center !important; }
.empty-block { display: grid; min-height: 110px; place-items: center; color: var(--dim); font-size: 11px; }
.readiness-card { grid-column: 2; grid-row: 1; }
.readiness-score { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; padding: 16px 0 19px; border-bottom: 1px solid var(--line-soft); }
.readiness-score strong { font: 720 31px ui-monospace, SFMono-Regular, Consolas, monospace; }
.readiness-score span { padding-bottom: 4px; color: var(--dim); font-size: 10px; }
.check-list { display: grid; gap: 11px; margin: 18px 0 0; padding: 0; list-style: none; }
.check-list li { display: flex; align-items: flex-start; gap: 9px; color: var(--muted); font-size: 10px; line-height: 1.45; }
.check-dot { width: 7px; height: 7px; margin-top: 3px; flex: 0 0 auto; border-radius: 50%; background: var(--green); }
.check-dot.is-bad { background: var(--red); }
.check-dot.is-warn { background: var(--amber); }
.position-list { display: grid; gap: 0; }
.position-row { display: grid; grid-template-columns: 1.1fr repeat(4, minmax(85px, .8fr)); align-items: center; gap: 14px; min-height: 60px; border-bottom: 1px solid var(--line-soft); }
.position-row:last-child { border-bottom: 0; }
.position-row > div { min-width: 0; text-align: right; }
.position-row > div:first-child { text-align: left; }
.position-row span { display: block; margin-bottom: 4px; color: var(--dim); font-size: 9px; }
.position-row strong { display: block; overflow: hidden; font: 650 11px ui-monospace, SFMono-Regular, Consolas, monospace; text-overflow: ellipsis; white-space: nowrap; }
.model-card { grid-column: 2; grid-row: 2; }
.model-glyph { display: grid; width: 34px; height: 34px; place-items: center; border: 1px solid #2d385b; border-radius: 7px; background: #151a2c; color: var(--blue); font: 700 18px Georgia, serif; }
.model-state { padding: 14px 0 20px; border-bottom: 1px solid var(--line-soft); }
.model-state strong { display: block; font-size: 15px; }
.model-state span { display: block; margin-top: 6px; color: var(--muted); font-size: 10px; line-height: 1.45; }
.compact-dl, .system-dl { margin: 14px 0 0; }
.compact-dl div, .system-dl div { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 9px 0; border-bottom: 1px solid var(--line-soft); }
.compact-dl div:last-child, .system-dl div:last-child { border-bottom: 0; }
.compact-dl dt, .system-dl dt { color: var(--dim); font-size: 10px; }
.compact-dl dd, .system-dl dd { margin: 0; font: 650 10px ui-monospace, SFMono-Regular, Consolas, monospace; text-align: right; }
.page-card { min-height: 460px; padding: 24px; }
.page-card-head { align-items: center; margin-bottom: 24px; }
.table-large td { height: 61px; }
.search-box { display: flex; width: 220px; height: 36px; align-items: center; gap: 8px; padding: 0 11px; border: 1px solid var(--line); border-radius: 7px; background: var(--bg); }
.search-box svg { width: 15px; height: 15px; fill: var(--dim); }
.search-box input { width: 100%; border: 0; outline: 0; background: transparent; color: var(--text); font-size: 11px; }
.search-box input::placeholder { color: var(--dim); }
.positions-metrics { margin-top: 0; margin-bottom: 12px; }
.activity-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
.activity-card { min-width: 0; padding: 22px; }
.feed { max-height: calc(100vh - 205px); min-height: 420px; overflow-y: auto; scrollbar-width: thin; scrollbar-color: var(--line) transparent; }
.feed-item { padding: 14px 0; border-bottom: 1px solid var(--line-soft); }
.feed-item:first-child { padding-top: 2px; }
.feed-item:last-child { border-bottom: 0; }
.feed-top { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.feed-top strong { font-size: 11px; }
.feed-top time { color: var(--dim); font: 9px ui-monospace, SFMono-Regular, Consolas, monospace; }
.feed-item p { margin: 7px 0 0; color: var(--muted); font-size: 10px; line-height: 1.55; }
.feed-meta { display: flex; gap: 10px; margin-top: 8px; color: var(--dim); font: 9px ui-monospace, SFMono-Regular, Consolas, monospace; }
.action-label { font-size: 9px !important; letter-spacing: .05em; }
.system-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.system-grid > .card { min-height: 290px; }
.control-buttons { display: flex; gap: 9px; margin-top: 24px; }
.setting-row { display: flex; align-items: center; justify-content: space-between; gap: 20px; margin-top: 26px; padding-top: 20px; border-top: 1px solid var(--line-soft); }
.setting-row strong { display: block; font-size: 11px; }
.setting-row span { display: block; margin-top: 5px; color: var(--dim); font-size: 10px; }
.switch { position: relative; display: inline-flex; cursor: pointer; }
.switch input { position: absolute; width: 1px; height: 1px; opacity: 0; }
.switch > span { position: relative; width: 40px; height: 22px; margin: 0; border: 1px solid var(--line); border-radius: 12px; background: var(--surface-3); transition: background .15s ease; }
.switch > span::after { position: absolute; top: 3px; left: 3px; width: 14px; height: 14px; border-radius: 50%; background: var(--muted); content: ""; transition: transform .15s ease, background .15s ease; }
.switch input:checked + span { border-color: #287258; background: #143c30; }
.switch input:checked + span::after { background: var(--green); transform: translateX(18px); }
.switch input:focus-visible + span { outline: 2px solid var(--blue); outline-offset: 2px; }
.guard-note { margin: 17px 0 0; padding: 11px 12px; border-left: 2px solid var(--amber); background: #17140f; color: #a99c88; font-size: 9px; line-height: 1.55; }
.dialog { width: min(430px, calc(100vw - 32px)); padding: 0; border: 1px solid var(--line); border-radius: 11px; background: #11151b; color: var(--text); box-shadow: 0 28px 90px rgba(0,0,0,.62); }
.dialog::backdrop { background: rgba(3,5,8,.82); backdrop-filter: blur(8px); }
.dialog form { padding: 28px; }
.dialog-icon { margin-bottom: 24px; }
.dialog h2 { margin: 0; font-size: 22px; letter-spacing: -.03em; }
.dialog p:not(.eyebrow):not(.form-error) { margin: 11px 0 22px; color: var(--muted); font-size: 11px; line-height: 1.6; }
.dialog label { display: block; margin-bottom: 8px; color: var(--muted); font-size: 10px; }
.dialog input { width: 100%; height: 40px; padding: 0 11px; border: 1px solid var(--line); border-radius: 7px; outline: 0; background: #090c10; color: var(--text); }
.dialog .button-wide { margin-top: 14px; }
.form-error { min-height: 16px; margin: 8px 0 0; color: var(--red); font-size: 9px; }
.dialog-actions { display: flex; justify-content: flex-end; gap: 9px; }
.toast { position: fixed; right: 24px; bottom: 24px; z-index: 50; max-width: 360px; padding: 12px 15px; border: 1px solid var(--line); border-radius: 8px; background: var(--surface-3); color: var(--text); font-size: 11px; opacity: 0; pointer-events: none; transform: translateY(12px); transition: opacity .16s ease, transform .16s ease; }
.toast.is-visible { opacity: 1; transform: translateY(0); }
.toast.is-error { border-color: #63313a; color: var(--red); }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; }
@media (max-width: 1100px) {
:root { --sidebar: 76px; }
.brand { justify-content: center; padding: 0; }
.brand > span:last-child, .nav-item span, .sidebar-foot { display: none; }
.nav-list { padding-inline: 11px; }
.nav-item { justify-content: center; padding-inline: 0; }
.content-grid { grid-template-columns: 1fr; }
.span-2, .readiness-card, .model-card { grid-column: 1; grid-row: auto; }
.activity-grid { grid-template-columns: 1fr; }
.feed { max-height: 520px; min-height: 280px; }
}
@media (max-width: 760px) {
:root { --sidebar: 0px; }
.sidebar { inset: auto 0 0; width: 100%; height: 64px; border-top: 1px solid var(--line); border-right: 0; }
.brand, .sidebar-foot { display: none; }
.nav-list { display: grid; height: 100%; grid-template-columns: repeat(5, 1fr); gap: 0; padding: 5px 8px; }
.nav-item { flex-direction: column; justify-content: center; gap: 4px; padding: 4px; font-size: 8px; }
.nav-item span { display: block; }
.nav-item svg { width: 16px; height: 16px; }
.main { margin-left: 0; padding-bottom: 64px; }
.topbar { min-height: 70px; padding: 0 18px; }
.topbar .eyebrow, .sync-label { display: none; }
.topbar h1 { font-size: 18px; }
.page { padding: 18px 14px 32px; }
.offline-banner { margin: 12px 14px 0; }
.hero { min-height: 0; grid-template-columns: 1fr; }
.hero-copy { padding: 27px 23px; }
.hero h2 { font-size: 30px; }
.hero-telemetry { grid-template-columns: repeat(3, 1fr); padding: 0 20px; border-top: 1px solid #1c2925; border-left: 0; }
.hero-telemetry div { display: block; padding: 15px 7px; border-right: 1px solid #1c2925; border-bottom: 0; text-align: center; }
.hero-telemetry div:last-child { border-right: 0; }
.hero-telemetry strong { display: block; margin-top: 6px; font-size: 10px; }
.metric-grid { grid-template-columns: repeat(2, minmax(0,1fr)); }
.metric { min-height: 112px; padding: 17px; }
.metric strong { font-size: 21px; }
.content-grid, .system-grid { grid-template-columns: 1fr; }
.system-grid > .card { min-height: 0; }
.page-card { padding: 18px; }
.page-card-head { align-items: flex-start; }
.search-box { width: 150px; }
.position-row { grid-template-columns: 1fr 1fr; padding: 12px 0; }
.position-row > div:nth-child(n+4) { display: none; }
.toast { right: 14px; bottom: 78px; left: 14px; max-width: none; }
}
@media (max-width: 440px) {
.badge-mode { display: none; }
.hero-actions, .control-buttons { display: grid; grid-template-columns: 1fr 1fr; }
.button { padding-inline: 11px; }
.metric-grid { gap: 8px; }
.metric { padding: 15px; }
.metric > span { font-size: 9px; }
.page-card-head { display: block; }
.search-box { width: 100%; margin-top: 16px; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; }
}
+534
View File
@@ -0,0 +1,534 @@
"use strict";
const state = {
snapshot: null,
token: "",
loading: false,
timer: null,
marketFilter: "",
};
const $ = (selector) => document.querySelector(selector);
const $$ = (selector) => Array.from(document.querySelectorAll(selector));
class AuthRequiredError extends Error {}
const reasonLabels = {
bot_not_running: "Торговый цикл остановлен",
decision_loop_stale: "Цикл принятия решений не обновлялся вовремя",
stale_market_data: "Есть устаревшие рыночные данные",
repeated_loop_errors: "Обнаружены повторяющиеся ошибки цикла",
forecast_model_not_ready: "Прогнозная модель не готова",
live_reconciliation_blocking: "Сверка live-счёта блокирует новые действия",
};
const actionLabels = {
BUY: "Покупка",
SELL: "Продажа",
HOLD: "Ожидание",
};
document.addEventListener("DOMContentLoaded", () => {
bindNavigation();
bindControls();
selectPage(location.hash.slice(1) || "overview", false);
loadSnapshot();
});
function bindNavigation() {
$$("[data-page]").forEach((button) => {
button.addEventListener("click", () => selectPage(button.dataset.page));
});
$$("[data-go]").forEach((button) => {
button.addEventListener("click", () => selectPage(button.dataset.go));
});
window.addEventListener("hashchange", () => selectPage(location.hash.slice(1) || "overview", false));
}
function selectPage(pageName, updateHash = true) {
const valid = ["overview", "markets", "positions", "activity", "system"];
const page = valid.includes(pageName) ? pageName : "overview";
$$(".page").forEach((item) => item.classList.toggle("is-active", item.id === `page-${page}`));
$$("[data-page]").forEach((item) => {
const active = item.dataset.page === page;
item.classList.toggle("is-active", active);
if (active) item.setAttribute("aria-current", "page");
else item.removeAttribute("aria-current");
});
const activePage = $(`#page-${page}`);
setText("pageTitle", activePage?.dataset.title || "Обзор");
if (updateHash && location.hash !== `#${page}`) history.pushState(null, "", `#${page}`);
window.scrollTo({ top: 0, behavior: "smooth" });
}
function bindControls() {
$("#refreshButton").addEventListener("click", () => loadSnapshot(true));
["#startButton", "#systemStartButton"].forEach((selector) => {
$(selector).addEventListener("click", () => requestControl("start"));
});
["#stopButton", "#systemStopButton"].forEach((selector) => {
$(selector).addEventListener("click", () => requestControl("stop"));
});
$("#fastTradingToggle").addEventListener("change", onFastTradingChange);
$("#marketSearch").addEventListener("input", (event) => {
state.marketFilter = event.target.value.trim().toUpperCase();
renderMarkets(state.snapshot?.markets?.markets || []);
});
$("#authForm").addEventListener("submit", async (event) => {
event.preventDefault();
state.token = $("#tokenInput").value.trim();
setText("authError", "");
if (!state.token) return;
await loadSnapshot(true, true);
});
}
async function api(path, options = {}) {
const headers = { Accept: "application/json", ...(options.headers || {}) };
if (options.body) headers["Content-Type"] = "application/json";
if (state.token) headers["X-TradeBot-Token"] = state.token;
const response = await fetch(path, {
...options,
headers,
credentials: "same-origin",
cache: "no-store",
});
if (response.status === 401) throw new AuthRequiredError("Требуется авторизация");
let payload = null;
try { payload = await response.json(); } catch (_) { payload = null; }
if (!response.ok) {
const detail = payload?.detail?.message || payload?.detail || payload?.error || `HTTP ${response.status}`;
throw new Error(typeof detail === "string" ? detail : JSON.stringify(detail));
}
return payload;
}
async function loadSnapshot(manual = false, fromAuth = false) {
if (state.loading) return;
state.loading = true;
clearTimeout(state.timer);
$("#refreshButton").classList.add("is-spinning");
if (manual) setText("syncLabel", "Обновление…");
try {
const snapshot = await api("/api/dashboard/snapshot");
state.snapshot = snapshot;
render(snapshot);
setOffline(false);
if ($("#authDialog").open) $("#authDialog").close();
setText("authError", "");
scheduleRefresh(10000);
} catch (error) {
if (error instanceof AuthRequiredError) {
if (fromAuth) setText("authError", "Токен не принят сервером.");
showAuthDialog();
} else {
setOffline(true, error.message);
scheduleRefresh(12000);
}
} finally {
state.loading = false;
$("#refreshButton").classList.remove("is-spinning");
}
}
function scheduleRefresh(delay) {
clearTimeout(state.timer);
state.timer = setTimeout(() => loadSnapshot(), delay);
}
function showAuthDialog() {
const dialog = $("#authDialog");
if (!dialog.open) dialog.showModal();
setText("syncLabel", "Нужна авторизация");
setTimeout(() => $("#tokenInput").focus(), 50);
}
function setOffline(offline, message = "") {
$("#offlineBanner").hidden = !offline;
setText("offlineMessage", message || "Повторное подключение выполняется автоматически.");
setText("sideConnection", offline ? "Нет связи" : "Сервер доступен");
$("#sideConnectionDot").classList.toggle("is-offline", offline);
$("#sideConnectionDot").classList.toggle("is-online", !offline);
if (offline) setText("syncLabel", "Соединение потеряно");
}
function render(data) {
const health = data.health || {};
const envelope = data.status || {};
const status = envelope.status || {};
const readiness = envelope.readiness || {};
const account = envelope.account || {};
const positions = envelope.positions || [];
const markets = data.markets || {};
const closed = data.trades?.closed_summary || {};
const config = data.config || {};
setText("appVersion", health.version || "—");
setText("modeBadge", String(health.mode || "—").toUpperCase());
setText("syncLabel", `Обновлено ${formatClock(data.generated_at)}`);
setText("sideConnection", "Сервер доступен");
$("#sideConnectionDot").classList.add("is-online");
renderHero(status, readiness, markets);
renderMetrics(account, positions, closed);
renderReadiness(status, readiness, markets);
renderModel(config, readiness, data.retrain || {}, markets);
renderOverviewMarkets(markets.markets || []);
renderMarkets(markets.markets || []);
renderPositions(positions, config);
renderActivity(data);
renderSystem(data);
}
function renderHero(status, readiness, markets) {
const running = Boolean(status.running);
const ready = Boolean(readiness.ready);
const orb = $("#heroOrb");
orb.classList.toggle("is-good", running && ready);
orb.classList.toggle("is-bad", !running);
if (!running) {
setText("heroKicker", "Цикл остановлен");
setText("heroTitle", "Бот сейчас не торгует");
setText("heroText", "Данные и позиции сохранены. Запуск возобновит анализ рынка и обработку торговых решений.");
} else if (ready) {
setText("heroKicker", "Контур готов");
setText("heroTitle", "Бот работает штатно");
setText("heroText", "Торговый цикл активен, рыночные данные свежие, обязательные проверки пройдены.");
} else {
setText("heroKicker", "Работа с ограничениями");
setText("heroTitle", "Бот активен, но есть предупреждения");
setText("heroText", (readiness.reasons || []).map(reasonLabel).join(" · ") || "Сервер сообщил об ограниченной готовности.");
}
setText("lastLoop", status.last_loop_at ? timeAgo(status.last_loop_at) : "нет данных");
setText("wsState", markets.ws_connected ? "подключён" : "нет связи");
setText("symbolCount", String((markets.symbols || []).length));
toggleControlButtons(running);
}
function toggleControlButtons(running) {
["#startButton", "#systemStartButton"].forEach((selector) => { $(selector).disabled = running; });
["#stopButton", "#systemStopButton"].forEach((selector) => { $(selector).disabled = !running; });
const badge = $("#controlBadge");
badge.textContent = running ? "Работает" : "Остановлен";
badge.className = `badge ${running ? "is-good" : "is-bad"}`;
}
function renderMetrics(account, positions, closed) {
const equity = number(account.equity);
const cash = number(account.cash);
const net = number(account.net_pnl);
const exposure = positions.reduce((sum, row) => sum + number(row.market_value), 0);
setText("metricEquity", money(equity));
setText("metricCash", money(cash));
setText("metricPositions", String(positions.length));
setText("metricExposure", `Экспозиция ${money(exposure)}`);
setText("metricTrades", String(closed.trades ?? 0));
setText("metricWinRate", `Win rate ${percent(number(closed.win_rate) * 100, 1)}`);
const delta = $("#metricEquityDelta");
delta.textContent = `${signedMoney(net)} · ${signedPercent(number(account.net_pnl_percent), 2)}`;
applyTone(delta, net);
}
function renderReadiness(status, readiness, markets) {
const ready = Boolean(readiness.ready);
const badge = $("#readyBadge");
badge.textContent = ready ? "Готов" : status.running ? "Ограничен" : "Стоп";
badge.className = `badge ${ready ? "is-good" : status.running ? "is-warn" : "is-bad"}`;
setText("readyScore", ready ? "READY" : "CHECK");
const checks = [
{ ok: Boolean(status.running), label: status.running ? "Торговый цикл запущен" : "Торговый цикл остановлен" },
{ ok: !(readiness.reasons || []).includes("decision_loop_stale"), label: "Цикл решений обновляется вовремя" },
{ ok: Boolean(markets.ws_connected), label: markets.ws_connected ? "Bybit WebSocket подключён" : "Bybit WebSocket не подключён" },
{ ok: !(readiness.stale_symbols || []).length, label: (readiness.stale_symbols || []).length ? `Устарели: ${readiness.stale_symbols.join(", ")}` : "Рыночные данные свежие" },
{
ok: Boolean(readiness.forecast_model_ready),
warn: Boolean(readiness.forecast_fallback_active),
label: readiness.forecast_model_ready ? "Прогнозная модель готова" : readiness.forecast_fallback_active ? "Активен резервный режим прогноза" : "Модель прогноза не готова",
},
];
$("#readinessList").innerHTML = checks.map((item) => `<li><span class="check-dot ${item.ok ? "" : item.warn ? "is-warn" : "is-bad"}"></span>${escapeHtml(item.label)}</li>`).join("");
}
function renderModel(config, readiness, retrain, markets) {
const artifact = config.time_series_model_artifact || {};
const shadow = retrain.shadow || {};
const collector = markets.observation_collector || {};
setText("modelTitle", artifact.available ? artifact.label || artifact.type || "Модель загружена" : "Артефакт недоступен");
setText("modelSubtitle", artifact.available ? `${artifact.symbol_count ?? 0} пар · создана ${formatDateTime(artifact.created_at)}` : "Сервер не подтвердил наличие модели");
setText("shadowGate", shadowStateLabel(shadow));
setText("fallbackState", readiness.forecast_fallback_active ? "активен" : "не активен");
setText("collectorState", collector.enabled ? `${collector.samples_since_start ?? 0} с запуска` : "выключен");
}
function renderOverviewMarkets(markets) {
const rows = markets.filter((market) => market.ticker).slice(0, 6);
$("#overviewMarkets").innerHTML = rows.length ? rows.map((market) => {
const ticker = market.ticker || {};
const forecast = market.forecast || {};
const quality = market.quality || {};
const change = number(ticker.change_24h);
const edge = forecastValue(forecast);
const forecastUsable = isForecastUsable(forecast);
return `<tr>
<td class="symbol-cell">${escapeHtml(ticker.symbol || "—")}<small>${escapeHtml(modelName(forecast))}</small></td>
<td>${formatPrice(ticker.last_price)}</td>
<td class="${toneClass(change)}">${signedPercent(change, 2)}</td>
<td class="${toneClass(edge)}">${forecastUsable ? signedPercent(edge, 2) : "—"}</td>
<td>${sparkline(market.sparkline || [], change)}</td>
<td>${qualityLabel(quality)}</td>
</tr>`;
}).join("") : `<tr><td colspan="6" class="empty">Рынок пока не вернул котировки.</td></tr>`;
}
function renderMarkets(markets) {
const filter = state.marketFilter;
const rows = markets.filter((market) => {
const symbol = market.ticker?.symbol || "";
return market.ticker && (!filter || symbol.includes(filter));
});
$("#marketsTable").innerHTML = rows.length ? rows.map((market) => {
const ticker = market.ticker || {};
const forecast = market.forecast || {};
const quality = market.quality || {};
const change = number(ticker.change_24h);
const edge = forecastValue(forecast);
const probability = probabilityValue(forecast);
const forecastUsable = isForecastUsable(forecast);
return `<tr>
<td class="symbol-cell">${escapeHtml(ticker.symbol || "—")}<small>${escapeHtml(modelName(forecast))}</small></td>
<td>${formatPrice(ticker.last_price)}</td>
<td>${formatPrice(ticker.bid)} / ${formatPrice(ticker.ask)}</td>
<td class="${toneClass(change)}">${signedPercent(change, 2)}</td>
<td class="${toneClass(edge)}">${forecastUsable ? signedPercent(edge, 2) : "—"}</td>
<td>${!forecastUsable || probability == null ? "—" : percent(probability * 100, 1)}</td>
<td>${percent(number(ticker.spread_percent), 3)}</td>
<td>${qualityLabel(quality)}</td>
</tr>`;
}).join("") : `<tr><td colspan="8" class="empty">${filter ? "Совпадений не найдено." : "Рынок пока не вернул котировки."}</td></tr>`;
}
function renderPositions(positions, config) {
const totalValue = positions.reduce((sum, row) => sum + number(row.market_value), 0);
const totalPnl = positions.reduce((sum, row) => sum + number(row.unrealized_pnl), 0);
const totalNotional = positions.reduce((sum, row) => sum + number(row.notional_usdt), 0);
const totalPnlPercent = totalNotional ? totalPnl / totalNotional * 100 : 0;
setText("positionCount", String(positions.length));
setText("positionValue", money(totalValue));
setText("positionPnl", signedMoney(totalPnl));
setText("positionPnlPercent", signedPercent(totalPnlPercent, 2));
setText("exposureLimit", money(config.max_total_exposure_usdt));
applyTone($("#positionPnl"), totalPnl);
applyTone($("#positionPnlPercent"), totalPnl);
$("#overviewPositions").innerHTML = positions.length ? positions.slice(0, 6).map((position) => `<div class="position-row">
<div><span>Пара</span><strong>${escapeHtml(position.symbol || "—")}</strong></div>
<div><span>Стоимость</span><strong>${money(position.market_value)}</strong></div>
<div><span>Цена сейчас</span><strong>${formatPrice(position.mark_price)}</strong></div>
<div><span>PnL</span><strong class="${toneClass(number(position.unrealized_pnl))}">${signedMoney(position.unrealized_pnl)}</strong></div>
<div><span>План</span><strong>${escapeHtml(actionLabels[position.exit_plan?.action] || position.exit_plan?.action || "Ожидание")}</strong></div>
</div>`).join("") : `<div class="empty-block">Открытых позиций нет.</div>`;
$("#positionsTable").innerHTML = positions.length ? positions.map((position) => `<tr>
<td class="symbol-cell">${escapeHtml(position.symbol || "—")}<small>${escapeHtml(position.mode || "")}</small></td>
<td>${formatQuantity(position.qty)}</td>
<td>${formatPrice(position.entry_price)}</td>
<td>${formatPrice(position.mark_price)}</td>
<td>${money(position.market_value)}</td>
<td class="${toneClass(number(position.unrealized_pnl))}">${signedMoney(position.unrealized_pnl)}<br><small>${signedPercent(number(position.unrealized_pnl_percent), 2)}</small></td>
<td>${escapeHtml(actionLabels[position.exit_plan?.action] || position.exit_plan?.action || "Ожидание")}</td>
<td>${formatDateTime(position.opened_at)}</td>
</tr>`).join("") : `<tr><td colspan="8" class="empty">Открытых позиций нет.</td></tr>`;
}
function renderActivity(data) {
const signals = data.signals?.items || [];
const trades = data.trades?.items || [];
const events = data.events?.items || [];
$("#signalFeed").innerHTML = signals.length ? signals.map((item) => {
const action = String(item.action || "HOLD").toUpperCase();
const tone = action === "BUY" ? "positive" : action === "SELL" ? "negative" : "warning";
return `<article class="feed-item"><div class="feed-top"><strong>${escapeHtml(item.symbol || "—")}</strong><time>${formatDateTime(item.created_at)}</time></div><p>${escapeHtml(item.reason || "Причина не указана")}</p><div class="feed-meta"><span class="action-label ${tone}">${escapeHtml(actionLabels[action] || action)}</span><span>confidence ${percent(number(item.confidence) * 100, 1)}</span></div></article>`;
}).join("") : `<div class="empty-block">Сигналов пока нет.</div>`;
$("#tradeFeed").innerHTML = trades.length ? trades.map((item) => {
const side = String(item.side || "").toUpperCase();
const pnl = number(item.net_pnl);
return `<article class="feed-item"><div class="feed-top"><strong>${escapeHtml(item.symbol || "—")} · <span class="${side === "SELL" ? "negative" : "positive"}">${escapeHtml(side)}</span></strong><time>${formatDateTime(item.closed_at || item.opened_at)}</time></div><p>${escapeHtml(item.reason || (side === "BUY" ? "Позиция открыта" : "Сделка исполнена"))}</p><div class="feed-meta"><span>${formatQuantity(item.qty)} ед.</span><span class="${toneClass(pnl)}">PnL ${signedMoney(pnl)}</span><span>fee ${money(item.fee_usdt)}</span></div></article>`;
}).join("") : `<div class="empty-block">Сделок пока нет.</div>`;
$("#eventFeed").innerHTML = events.length ? events.map((item) => {
const level = String(item.level || "INFO").toUpperCase();
const tone = level === "ERROR" ? "negative" : level === "WARN" ? "warning" : "";
return `<article class="feed-item"><div class="feed-top"><strong class="${tone}">${escapeHtml(level)}</strong><time>${formatDateTime(item.created_at)}</time></div><p>${escapeHtml(item.message || "—")}</p></article>`;
}).join("") : `<div class="empty-block">Событий пока нет.</div>`;
}
function renderSystem(data) {
const config = data.config || {};
const markets = data.markets || {};
const retrain = data.retrain || {};
const coordination = retrain.coordination || {};
const shadow = retrain.shadow || {};
const activeJob = coordination.active_job || coordination.latest_job;
$("#fastTradingToggle").checked = Boolean(config.fast_trading_enabled);
setText("fastTradingHint", `Интервал ${formatDuration(config.effective_loop_interval_seconds)} · cooldown ${formatDuration(config.effective_entry_cooldown_seconds)}`);
$("#trainingDetails").innerHTML = definitionRows([
["Модель", config.time_series_model_artifact?.label || "нет данных"],
["Windows-агент", coordination.agent_online ? coordination.agent_busy ? "занят" : "онлайн" : "не в сети"],
["Последняя задача", activeJob?.status || "нет задач"],
["Shadow gate", shadowStateLabel(shadow)],
["Forward predictions", `${shadow.settled_predictions ?? 0} settled / ${shadow.eligible_predictions ?? 0} eligible`],
]);
$("#networkDetails").innerHTML = definitionRows([
["WebSocket", markets.ws_connected ? "подключён" : "нет связи"],
["Последнее WS-сообщение", formatDateTime(markets.last_ws_message_at)],
["Последний REST refresh", formatDateTime(markets.last_rest_refresh_at)],
["REST-ошибки", String(markets.rest_error_count ?? 0)],
["Сбор L1", markets.observation_collector?.enabled ? `включён · ${markets.observation_collector.samples_since_start ?? 0}` : "выключен"],
]);
$("#configDetails").innerHTML = definitionRows([
["Стратегия", config.strategy_mode || "—"],
["Базовый интервал", config.base_interval ? `${config.base_interval} мин` : "—"],
["Profit-only выход", config.profit_only_exit_enabled ? `включён · min ${percent(config.min_exit_net_percent, 2)}` : "выключен"],
["Risk guard", config.risk_guard_enabled ? "включён" : "выключен"],
["Общая экспозиция", `${money(config.max_total_exposure_usdt)} USDT`],
["Макс. позиций", String(config.max_open_positions ?? "—")],
]);
}
async function requestControl(action) {
const start = action === "start";
const confirmed = await askConfirm(
start ? "Запустить торговый цикл?" : "Остановить торговый цикл?",
start
? "Бот возобновит анализ рынка и обработку решений. Текущий режим торговли не изменится."
: "Новые решения перестанут обрабатываться. Открытые позиции и история останутся сохранены.",
start ? "Запустить" : "Остановить",
);
if (!confirmed) return;
setControlsBusy(true);
try {
await api(`/api/control/${action}`, { method: "POST" });
toast(start ? "Торговый цикл запущен." : "Торговый цикл остановлен.");
await loadSnapshot(true);
} catch (error) {
if (error instanceof AuthRequiredError) showAuthDialog();
else toast(error.message, true);
} finally {
toggleControlButtons(Boolean(state.snapshot?.status?.status?.running));
}
}
async function onFastTradingChange(event) {
const toggle = event.target;
const previous = !toggle.checked;
const enabled = toggle.checked;
const confirmed = await askConfirm(
enabled ? "Включить быструю торговлю?" : "Выключить быструю торговлю?",
enabled
? "Сервер уменьшит интервал цикла и cooldown входа согласно текущей конфигурации."
: "Сервер вернётся к обычному интервалу принятия решений.",
enabled ? "Включить" : "Выключить",
);
if (!confirmed) { toggle.checked = previous; return; }
toggle.disabled = true;
try {
const result = await api("/api/config/fast-trading", { method: "POST", body: JSON.stringify({ enabled }) });
toast(`Быстрая торговля ${enabled ? "включена" : "выключена"}${result.env_persisted === false ? " только в runtime" : ""}.`);
await loadSnapshot(true);
} catch (error) {
toggle.checked = previous;
if (error instanceof AuthRequiredError) showAuthDialog();
else toast(error.message, true);
} finally {
toggle.disabled = false;
}
}
function askConfirm(title, text, actionLabel) {
const dialog = $("#confirmDialog");
setText("confirmTitle", title);
setText("confirmText", text);
setText("confirmAction", actionLabel);
dialog.showModal();
return new Promise((resolve) => {
dialog.addEventListener("close", () => resolve(dialog.returnValue === "confirm"), { once: true });
});
}
function setControlsBusy(busy) {
["#startButton", "#stopButton", "#systemStartButton", "#systemStopButton"].forEach((selector) => { $(selector).disabled = busy; });
}
function definitionRows(rows) {
return rows.map(([term, value]) => `<div><dt>${escapeHtml(term)}</dt><dd>${escapeHtml(value ?? "—")}</dd></div>`).join("");
}
function sparkline(points, change) {
const values = points.map((point) => number(point.close)).filter((value) => Number.isFinite(value) && value > 0);
if (values.length < 2) return "—";
const min = Math.min(...values);
const max = Math.max(...values);
const range = max - min || 1;
const path = values.map((value, index) => `${(index / (values.length - 1) * 88).toFixed(1)},${(26 - ((value - min) / range) * 22).toFixed(1)}`).join(" ");
return `<svg class="sparkline ${change < 0 ? "is-down" : ""}" viewBox="0 0 88 28" aria-hidden="true"><polyline points="${path}"/></svg>`;
}
function qualityLabel(quality) {
const status = quality?.status || "unknown";
const text = status === "ok" ? "Норма" : status === "warn" ? "Внимание" : status === "error" ? "Ошибка" : "Нет данных";
const tone = status === "ok" ? "positive" : status === "warn" ? "warning" : status === "error" ? "negative" : "";
return `<span class="row-state ${tone}">${text}</span>`;
}
function modelName(forecast) {
if (!isForecastUsable(forecast)) return "нет модели";
return forecast?.model_label || forecast?.model || "прогноз";
}
function isForecastUsable(forecast) {
if (!forecast || forecast.usable === false) return false;
return Boolean(forecast.usable || (forecast.model && forecast.model !== "none"));
}
function forecastValue(forecast) {
return number(forecast?.expected_return_percent ?? forecast?.edge_percent ?? forecast?.expected_percent);
}
function probabilityValue(forecast) {
const raw = forecast?.probability_up ?? forecast?.probability;
if (raw === null || raw === undefined || raw === "") return null;
const value = number(raw);
return value > 1 ? value / 100 : value;
}
function reasonLabel(reason) { return reasonLabels[reason] || String(reason || "Неизвестное ограничение"); }
function shadowStateLabel(shadow) { if (shadow?.passed) return "пройден"; return ({ collecting: "сбор данных", failed: "не пройден", passed: "пройден" })[shadow?.state] || "нет данных"; }
function number(value) { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : 0; }
function setText(id, value) { const node = document.getElementById(id); if (node) node.textContent = String(value ?? "—"); }
function money(value) { return number(value).toLocaleString("ru-RU", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); }
function signedMoney(value) { const amount = number(value); return `${amount > 0 ? "+" : ""}${money(amount)} USDT`; }
function percent(value, digits = 2) { return `${number(value).toLocaleString("ru-RU", { minimumFractionDigits: digits, maximumFractionDigits: digits })}%`; }
function signedPercent(value, digits = 2) { const amount = number(value); return `${amount > 0 ? "+" : ""}${percent(amount, digits)}`; }
function formatPrice(value) { const amount = number(value); if (!amount) return "—"; const digits = amount >= 1000 ? 2 : amount >= 1 ? 4 : 6; return amount.toLocaleString("ru-RU", { maximumFractionDigits: digits }); }
function formatQuantity(value) { return number(value).toLocaleString("ru-RU", { maximumFractionDigits: 8 }); }
function formatDuration(value) { const seconds = number(value); return seconds < 60 ? `${seconds.toLocaleString("ru-RU", { maximumFractionDigits: 1 })} с` : `${(seconds / 60).toLocaleString("ru-RU", { maximumFractionDigits: 1 })} мин`; }
function formatClock(value) { const date = new Date(value); return Number.isNaN(date.getTime()) ? "—" : date.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit", second: "2-digit" }); }
function formatDateTime(value) { const date = new Date(value); return !value || Number.isNaN(date.getTime()) ? "—" : date.toLocaleString("ru-RU", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" }); }
function timeAgo(value) { const date = new Date(value); if (Number.isNaN(date.getTime())) return "—"; const seconds = Math.max(0, Math.round((Date.now() - date.getTime()) / 1000)); if (seconds < 5) return "сейчас"; if (seconds < 60) return `${seconds} с назад`; const minutes = Math.round(seconds / 60); if (minutes < 60) return `${minutes} мин назад`; return formatDateTime(value); }
function toneClass(value) { return number(value) > 0 ? "positive" : number(value) < 0 ? "negative" : ""; }
function applyTone(node, value) { node.classList.remove("positive", "negative"); if (number(value) > 0) node.classList.add("positive"); if (number(value) < 0) node.classList.add("negative"); }
function escapeHtml(value) { return String(value ?? "").replace(/[&<>'"]/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", "'": "&#39;", '"': "&quot;" }[char])); }
let toastTimer = null;
function toast(message, error = false) {
const node = $("#toast");
node.textContent = message;
node.classList.toggle("is-error", error);
node.classList.add("is-visible");
clearTimeout(toastTimer);
toastTimer = setTimeout(() => node.classList.remove("is-visible"), 3500);
}
+218
View File
@@ -0,0 +1,218 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<meta name="theme-color" content="#090b0f">
<meta name="description" content="Операционная панель TradeBot">
<title>TradeBot — панель управления</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%23111318'/%3E%3Cpath d='M14 17h36v8H36v24h-8V25H14z' fill='%2326d99a'/%3E%3C/svg%3E">
<link rel="stylesheet" href="/assets/dashboard.css?v=3">
</head>
<body>
<div class="app-shell">
<aside class="sidebar" aria-label="Основная навигация">
<a class="brand" href="#overview" aria-label="TradeBot — обзор">
<span class="brand-mark" aria-hidden="true">T</span>
<span><strong>TradeBot</strong><small>Operations</small></span>
</a>
<nav class="nav-list">
<button class="nav-item is-active" type="button" data-page="overview" aria-current="page">
<svg aria-hidden="true" viewBox="0 0 24 24"><path d="M4 13h6V4H4v9Zm0 7h6v-5H4v5Zm10 0h6v-9h-6v9Zm0-16v5h6V4h-6Z"/></svg>
<span>Обзор</span>
</button>
<button class="nav-item" type="button" data-page="markets">
<svg aria-hidden="true" viewBox="0 0 24 24"><path d="m4 17 5-5 4 3 7-8v3l-7 8-4-3-5 5v-3Z"/></svg>
<span>Рынки</span>
</button>
<button class="nav-item" type="button" data-page="positions">
<svg aria-hidden="true" viewBox="0 0 24 24"><path d="M4 6h16v12H4V6Zm2 3v6h12V9H6Zm2 1h4v4H8v-4Z"/></svg>
<span>Позиции</span>
</button>
<button class="nav-item" type="button" data-page="activity">
<svg aria-hidden="true" viewBox="0 0 24 24"><path d="M5 4h14v3H5V4Zm0 6h14v3H5v-3Zm0 6h14v3H5v-3Z"/></svg>
<span>Активность</span>
</button>
<button class="nav-item" type="button" data-page="system">
<svg aria-hidden="true" viewBox="0 0 24 24"><path d="M12 8a4 4 0 1 1 0 8 4 4 0 0 1 0-8Zm9 4-2.1-1.2.1-2.4-2.2-2.2-2.4.1L13.2 4h-2.4L9.6 6.3l-2.4-.1L5 8.4l.1 2.4L3 12l2.1 1.2-.1 2.4 2.2 2.2 2.4-.1 1.2 2.3h2.4l1.2-2.3 2.4.1 2.2-2.2-.1-2.4L21 12Z"/></svg>
<span>Система</span>
</button>
</nav>
<div class="sidebar-foot">
<div class="connection-mini">
<span class="live-dot" id="sideConnectionDot"></span>
<span><strong id="sideConnection">Подключение…</strong><small>tb.kusoft.xyz</small></span>
</div>
<div class="version-line">Версия <span id="appVersion"></span></div>
</div>
</aside>
<main class="main">
<header class="topbar">
<div>
<p class="eyebrow" id="pageEyebrow">Операционная панель</p>
<h1 id="pageTitle">Обзор</h1>
</div>
<div class="topbar-actions">
<span class="sync-label" id="syncLabel">Получение данных…</span>
<span class="badge badge-mode" id="modeBadge"></span>
<button class="icon-button" id="refreshButton" type="button" aria-label="Обновить данные" title="Обновить">
<svg aria-hidden="true" viewBox="0 0 24 24"><path d="M19 8V4l-1.6 1.6A8 8 0 1 0 20 12h-2a6 6 0 1 1-2-4.5L14 9h5V8Z"/></svg>
</button>
</div>
</header>
<div class="offline-banner" id="offlineBanner" role="alert" hidden>
<strong>Нет связи с сервером.</strong>
<span id="offlineMessage">Повторное подключение выполняется автоматически.</span>
</div>
<section class="page is-active" id="page-overview" data-title="Обзор">
<article class="hero card">
<div class="hero-copy">
<div class="status-kicker"><span class="status-orb" id="heroOrb"></span><span id="heroKicker">Проверка контура</span></div>
<h2 id="heroTitle">Получаем состояние бота</h2>
<p id="heroText">Панель сверяет торговый цикл, рыночные данные и готовность модели.</p>
<div class="hero-actions">
<button class="button button-primary" id="startButton" type="button">Запустить цикл</button>
<button class="button button-danger" id="stopButton" type="button">Остановить</button>
</div>
</div>
<div class="hero-telemetry">
<div><span>Последний цикл</span><strong id="lastLoop"></strong></div>
<div><span>WebSocket</span><strong id="wsState"></strong></div>
<div><span>Торговых пар</span><strong id="symbolCount"></strong></div>
</div>
</article>
<div class="metric-grid" aria-label="Ключевые показатели">
<article class="metric card"><span>Капитал</span><strong id="metricEquity"></strong><small id="metricEquityDelta">С начала работы</small></article>
<article class="metric card"><span>Свободно</span><strong id="metricCash"></strong><small>USDT для новых позиций</small></article>
<article class="metric card"><span>Открытые позиции</span><strong id="metricPositions"></strong><small id="metricExposure">Экспозиция —</small></article>
<article class="metric card"><span>Закрытые сделки</span><strong id="metricTrades"></strong><small id="metricWinRate">Win rate —</small></article>
</div>
<div class="content-grid overview-grid">
<article class="card span-2">
<div class="card-head"><div><p class="eyebrow">Live market</p><h2>Рынок и прогноз</h2></div><button class="text-button" type="button" data-go="markets">Все пары</button></div>
<div class="table-wrap"><table><thead><tr><th>Пара</th><th>Цена</th><th>24 часа</th><th>Прогноз</th><th>Динамика</th><th>Качество</th></tr></thead><tbody id="overviewMarkets"><tr><td colspan="6" class="empty">Загрузка рынка…</td></tr></tbody></table></div>
</article>
<article class="card readiness-card">
<div class="card-head"><div><p class="eyebrow">Safety</p><h2>Готовность</h2></div><span class="badge" id="readyBadge">Проверка</span></div>
<div class="readiness-score"><strong id="readyScore"></strong><span>торговый контур</span></div>
<ul class="check-list" id="readinessList"><li><span class="check-dot"></span>Получение состояния…</li></ul>
</article>
<article class="card span-2">
<div class="card-head"><div><p class="eyebrow">Portfolio</p><h2>Открытые позиции</h2></div><button class="text-button" type="button" data-go="positions">Подробнее</button></div>
<div id="overviewPositions" class="position-list"><div class="empty-block">Позиции загружаются…</div></div>
</article>
<article class="card model-card">
<div class="card-head"><div><p class="eyebrow">Model</p><h2>Прогнозная модель</h2></div><span class="model-glyph">ƒ</span></div>
<div class="model-state"><strong id="modelTitle"></strong><span id="modelSubtitle">Проверка артефакта</span></div>
<dl class="compact-dl">
<div><dt>Forward gate</dt><dd id="shadowGate"></dd></div>
<div><dt>Fallback</dt><dd id="fallbackState"></dd></div>
<div><dt>Сбор L1</dt><dd id="collectorState"></dd></div>
</dl>
</article>
</div>
</section>
<section class="page" id="page-markets" data-title="Рынки">
<article class="card page-card">
<div class="card-head page-card-head"><div><p class="eyebrow">Bybit spot</p><h2>Торговая вселенная</h2><p class="subtext">Котировки, прогноз, спред и состояние данных по активным парам.</p></div><div class="search-box"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M10 4a6 6 0 1 0 3.9 10.6L19.3 20l.7-.7-5.4-5.4A6 6 0 0 0 10 4Zm0 2a4 4 0 1 1 0 8 4 4 0 0 1 0-8Z"/></svg><input id="marketSearch" type="search" placeholder="Найти пару" aria-label="Найти торговую пару"></div></div>
<div class="table-wrap table-large"><table><thead><tr><th>Пара</th><th>Цена</th><th>Bid / Ask</th><th>24 часа</th><th>Прогноз</th><th>P(up)</th><th>Спред</th><th>Данные</th></tr></thead><tbody id="marketsTable"><tr><td colspan="8" class="empty">Загрузка рынка…</td></tr></tbody></table></div>
</article>
</section>
<section class="page" id="page-positions" data-title="Позиции">
<div class="metric-grid positions-metrics">
<article class="metric card"><span>Открыто</span><strong id="positionCount"></strong><small>позиций</small></article>
<article class="metric card"><span>Рыночная стоимость</span><strong id="positionValue"></strong><small>USDT</small></article>
<article class="metric card"><span>Нереализованный PnL</span><strong id="positionPnl"></strong><small id="positionPnlPercent"></small></article>
<article class="metric card"><span>Лимит экспозиции</span><strong id="exposureLimit"></strong><small>USDT</small></article>
</div>
<article class="card page-card">
<div class="card-head"><div><p class="eyebrow">Portfolio</p><h2>Все открытые позиции</h2></div></div>
<div class="table-wrap table-large"><table><thead><tr><th>Пара</th><th>Объём</th><th>Вход</th><th>Сейчас</th><th>Стоимость</th><th>PnL</th><th>План выхода</th><th>Открыта</th></tr></thead><tbody id="positionsTable"><tr><td colspan="8" class="empty">Загрузка позиций…</td></tr></tbody></table></div>
</article>
</section>
<section class="page" id="page-activity" data-title="Активность">
<div class="activity-grid">
<article class="card activity-card">
<div class="card-head"><div><p class="eyebrow">Strategy</p><h2>Последние сигналы</h2></div></div>
<div class="feed" id="signalFeed"><div class="empty-block">Загрузка сигналов…</div></div>
</article>
<article class="card activity-card">
<div class="card-head"><div><p class="eyebrow">Execution</p><h2>Сделки</h2></div></div>
<div class="feed" id="tradeFeed"><div class="empty-block">Загрузка сделок…</div></div>
</article>
<article class="card activity-card">
<div class="card-head"><div><p class="eyebrow">System log</p><h2>События</h2></div></div>
<div class="feed" id="eventFeed"><div class="empty-block">Загрузка событий…</div></div>
</article>
</div>
</section>
<section class="page" id="page-system" data-title="Система">
<div class="system-grid">
<article class="card control-card">
<div class="card-head"><div><p class="eyebrow">Control</p><h2>Управление циклом</h2></div><span class="badge" id="controlBadge"></span></div>
<p class="subtext">Остановка завершает торговый цикл, но не удаляет позиции и данные. Запуск возобновляет обработку рынка.</p>
<div class="control-buttons"><button class="button button-primary" id="systemStartButton" type="button">Запустить</button><button class="button button-danger" id="systemStopButton" type="button">Остановить</button></div>
<div class="setting-row"><div><strong>Быстрая торговля</strong><span id="fastTradingHint">Уменьшенный интервал принятия решений</span></div><label class="switch"><input id="fastTradingToggle" type="checkbox"><span aria-hidden="true"></span><b class="sr-only">Переключить быструю торговлю</b></label></div>
</article>
<article class="card">
<div class="card-head"><div><p class="eyebrow">Model runtime</p><h2>Обучение и модель</h2></div></div>
<dl class="system-dl" id="trainingDetails"><div><dt>Состояние</dt><dd>Загрузка…</dd></div></dl>
<p class="guard-note">Запуск обучения и продвижение shadow-модели доступны только после серверной проверки gate и намеренно не выполняются этой панелью автоматически.</p>
</article>
<article class="card">
<div class="card-head"><div><p class="eyebrow">Connectivity</p><h2>Рыночные данные</h2></div></div>
<dl class="system-dl" id="networkDetails"><div><dt>Состояние</dt><dd>Загрузка…</dd></div></dl>
</article>
<article class="card">
<div class="card-head"><div><p class="eyebrow">Runtime</p><h2>Безопасная конфигурация</h2></div></div>
<dl class="system-dl" id="configDetails"><div><dt>Состояние</dt><dd>Загрузка…</dd></div></dl>
</article>
</div>
</section>
</main>
</div>
<dialog class="dialog" id="authDialog">
<form id="authForm">
<div class="dialog-icon">T</div>
<p class="eyebrow">Защищённый доступ</p>
<h2>Требуется API-токен</h2>
<p>Прокси-авторизация не обнаружена. Токен останется только в памяти этой вкладки и не будет сохранён.</p>
<label for="tokenInput">Токен TradeBot</label>
<input id="tokenInput" type="password" autocomplete="current-password" required>
<p class="form-error" id="authError" role="alert"></p>
<button class="button button-primary button-wide" type="submit">Подключиться</button>
</form>
</dialog>
<dialog class="dialog dialog-confirm" id="confirmDialog">
<form method="dialog">
<p class="eyebrow">Подтверждение действия</p>
<h2 id="confirmTitle">Подтвердите действие</h2>
<p id="confirmText"></p>
<div class="dialog-actions"><button class="button button-secondary" value="cancel">Отмена</button><button class="button button-danger" id="confirmAction" value="confirm">Подтвердить</button></div>
</form>
</dialog>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="/assets/dashboard.js?v=3" defer></script>
</body>
</html>
+40 -4
View File
@@ -2,9 +2,10 @@ from __future__ import annotations
import json
from crypto_spot_bot.dashboard import _compact_markets
from crypto_spot_bot.dashboard import _apply_fast_trading
from crypto_spot_bot.dashboard import _safe_config
from crypto_spot_bot.dashboard import WEB_UI_REMOVED_MESSAGE
from crypto_spot_bot.dashboard import WEB_INDEX
from crypto_spot_bot.storage import Storage
@@ -61,6 +62,41 @@ def test_safe_config_summarizes_torch_forecast_artifact(make_settings, tmp_path)
}
def test_web_ui_is_removed_from_api_service() -> None:
assert "Web UI removed" in WEB_UI_REMOVED_MESSAGE
assert "/api/*" in WEB_UI_REMOVED_MESSAGE
def test_web_ui_assets_are_available() -> None:
html = WEB_INDEX.read_text(encoding="utf-8")
assert "TradeBot — панель управления" in html
assert "/assets/dashboard.css" in html
assert "/assets/dashboard.js" in html
def test_compact_markets_keeps_dashboard_fields_and_limits_candles() -> None:
candles = [{"timestamp": index, "close": 100 + index, "open": 1} for index in range(60)]
compact = _compact_markets(
{
"symbols": ["BTCUSDT"],
"ws_connected": True,
"rest_error_count": 2,
"markets": [
{
"ticker": {"symbol": "BTCUSDT", "last_price": 160},
"candles": candles,
"forecast": {"expected_return_percent": 0.4},
"shadow_forecast": {"expected_return_percent": 0.5},
"orderbook": {"spread_bps": 1.2},
"quality": {"status": "ok"},
"instrument": {"symbol": "BTCUSDT"},
}
],
}
)
assert compact["ws_connected"] is True
assert compact["rest_error_count"] == 2
assert compact["markets"][0]["ticker"]["symbol"] == "BTCUSDT"
assert len(compact["markets"][0]["sparkline"]) == 48
assert compact["markets"][0]["sparkline"][0] == {"timestamp": 12, "close": 112}
assert "instrument" not in compact["markets"][0]
assert "orderbook" not in compact["markets"][0]
assert "shadow_forecast" not in compact["markets"][0]