feat: add TradeBot web control panel
This commit is contained in:
@@ -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-данных.
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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) => ({ "&": "&", "<": "<", ">": ">", "'": "'", '"': """ }[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);
|
||||
}
|
||||
@@ -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
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user