Files
TradeBot/crypto_spot_bot/dashboard.py
T
2026-06-24 21:31:05 +03:00

1072 lines
46 KiB
Python

from __future__ import annotations
import json
from contextlib import asynccontextmanager
from typing import Any
from fastapi import FastAPI, Response
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse
from crypto_spot_bot.analytics import analytics_snapshot
from crypto_spot_bot.bot import CryptoSpotBot
from crypto_spot_bot.bybit import BybitClient
from crypto_spot_bot.config import Settings, load_settings, update_env_value
from crypto_spot_bot.execution import LiveBroker, PaperBroker
from crypto_spot_bot.learning import TradeLearner
from crypto_spot_bot.market_data import MarketData
from crypto_spot_bot.patterns import PatternAnalyzer
from crypto_spot_bot.reconciliation import reconciliation_snapshot
from crypto_spot_bot.storage import Storage
from crypto_spot_bot.strategy import SpotStrategy
from crypto_spot_bot.time_series import TimeSeriesForecaster
def create_app(settings: Settings | None = None) -> FastAPI:
settings = settings or load_settings()
storage = Storage(settings.database_path)
runtime_fast_trading = storage.get_runtime("fast_trading_enabled", None)
if isinstance(runtime_fast_trading, bool):
settings.fast_trading_enabled = runtime_fast_trading
client = BybitClient(settings)
market = MarketData(settings, client, storage)
broker: PaperBroker | LiveBroker
if settings.trading_mode == "live":
broker = LiveBroker(settings, storage, client)
else:
broker = PaperBroker(settings, storage)
strategy = SpotStrategy(settings)
pattern_analyzer = PatternAnalyzer()
learner = TradeLearner(settings, storage)
forecaster = TimeSeriesForecaster(settings)
bot = CryptoSpotBot(settings, storage, market, broker, strategy, pattern_analyzer, learner, forecaster)
@asynccontextmanager
async def lifespan(_: FastAPI):
await bot.start()
try:
yield
finally:
await bot.stop()
app = FastAPI(title="Крипто спот-бот", lifespan=lifespan)
app.state.settings = settings
app.state.storage = storage
app.state.bot = bot
app.state.market = market
@app.get("/", response_class=HTMLResponse)
async def index() -> str:
return HTML
@app.get("/api/health")
async def health() -> dict[str, Any]:
return {"ok": True, "running": bot.running, "mode": settings.trading_mode}
@app.get("/api/status")
async def status() -> dict[str, Any]:
return {
"status": bot.status().as_dict(),
"account": bot.account_snapshot(),
"positions": bot.positions_snapshot(),
"learning": bot.learning_snapshot(),
"latest_equity": storage.latest_equity(),
}
@app.get("/api/markets")
async def markets() -> dict[str, Any]:
return market.snapshot()
@app.get("/api/trades")
async def trades(limit: int = 80) -> dict[str, Any]:
return {"items": storage.recent_trades(_limit(limit))}
@app.get("/api/signals")
async def signals(limit: int = 120) -> dict[str, Any]:
return {"items": storage.recent_signals(_limit(limit))}
@app.get("/api/events")
async def events(limit: int = 120) -> dict[str, Any]:
return {"items": storage.recent_events(_limit(limit))}
@app.get("/api/analytics")
async def analytics() -> dict[str, Any]:
return analytics_snapshot(settings, storage)
@app.get("/api/quality")
async def quality() -> dict[str, Any]:
return market.snapshot().get("quality", {})
@app.get("/api/reconciliation")
async def reconciliation() -> dict[str, Any]:
return reconciliation_snapshot(
settings=settings,
storage=storage,
client=client,
instruments=market.instruments,
)
@app.get("/api/backtest")
async def backtest() -> dict[str, Any]:
return _runtime_json(settings, "torch_threshold_calibration.json")
@app.get("/api/retrain")
async def retrain() -> dict[str, Any]:
return _runtime_json(settings, "torch_retrain_guard.json")
@app.get("/api/config")
async def config() -> dict[str, Any]:
return _safe_config(settings)
@app.post("/api/config/fast-trading")
async def set_fast_trading(payload: dict[str, Any]) -> dict[str, Any]:
enabled = _enabled_from_payload(payload)
env_persisted = _apply_fast_trading(settings, storage, enabled)
response = _safe_config(settings)
response["env_persisted"] = env_persisted
return response
@app.post("/api/control/start")
async def start() -> dict[str, Any]:
await bot.start()
return bot.status().as_dict()
@app.post("/api/control/stop")
async def stop() -> dict[str, Any]:
await bot.stop()
return bot.status().as_dict()
@app.get("/metrics")
async def metrics() -> Response:
account = bot.account_snapshot()
lines = [
"# HELP tradebot_equity_usdt Current account equity.",
"# TYPE tradebot_equity_usdt gauge",
f"tradebot_equity_usdt {account['equity']:.8f}",
"# HELP tradebot_cash_usdt Current free USDT cash.",
"# TYPE tradebot_cash_usdt gauge",
f"tradebot_cash_usdt {account['cash']:.8f}",
"# HELP tradebot_open_positions Open positions count.",
"# TYPE tradebot_open_positions gauge",
f"tradebot_open_positions {len(bot.positions_snapshot())}",
"# HELP tradebot_websocket_connected Bybit WebSocket connection status.",
"# TYPE tradebot_websocket_connected gauge",
f"tradebot_websocket_connected {1 if market.ws_connected else 0}",
"# HELP tradebot_fast_trading_enabled Fast trading mode status.",
"# TYPE tradebot_fast_trading_enabled gauge",
f"tradebot_fast_trading_enabled {1 if settings.fast_trading_enabled else 0}",
"# HELP tradebot_loop_interval_seconds Effective bot decision loop interval.",
"# TYPE tradebot_loop_interval_seconds gauge",
f"tradebot_loop_interval_seconds {settings.effective_loop_interval_seconds:.4f}",
]
return PlainTextResponse("\n".join(lines) + "\n")
@app.exception_handler(Exception)
async def error_handler(_, exc: Exception) -> JSONResponse:
storage.event(f"API error: {exc}", "ERROR")
return JSONResponse({"error": str(exc)}, status_code=500)
return app
def _limit(value: int) -> int:
return max(1, min(int(value), 500))
def _enabled_from_payload(payload: dict[str, Any]) -> bool:
value = payload.get("enabled")
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.strip().lower() in {"1", "true", "yes", "y", "on", "вкл", "включено"}
return bool(value)
def _apply_fast_trading(settings: Settings, storage: Storage, enabled: bool) -> bool:
settings.fast_trading_enabled = enabled
storage.set_runtime("fast_trading_enabled", enabled)
env_persisted = True
try:
update_env_value(settings.env_file_path, "FAST_TRADING_ENABLED", "true" if enabled else "false")
except OSError as exc:
env_persisted = False
storage.event(f"Быстрая торговля изменена только в runtime, .env не записан: {exc}", "WARN")
state = "включена" if enabled else "выключена"
storage.event(f"Быстрая торговля {state}")
return env_persisted
def _safe_config(settings: Settings) -> dict[str, Any]:
return {
"trading_mode": settings.trading_mode,
"bybit_testnet": settings.bybit_testnet,
"starting_balance_usdt": settings.starting_balance_usdt,
"auto_select_symbols": settings.auto_select_symbols,
"top_symbols_count": settings.top_symbols_count,
"symbols": settings.symbols,
"strategy_mode": settings.strategy_mode,
"base_interval": settings.base_interval,
"kline_limit": settings.kline_limit,
"trend_interval": settings.trend_interval,
"trend_kline_limit": settings.trend_kline_limit,
"loop_interval_seconds": settings.loop_interval_seconds,
"fast_trading_enabled": settings.fast_trading_enabled,
"fast_loop_interval_seconds": settings.fast_loop_interval_seconds,
"effective_loop_interval_seconds": settings.effective_loop_interval_seconds,
"fast_entry_cooldown_seconds": settings.fast_entry_cooldown_seconds,
"effective_entry_cooldown_seconds": settings.effective_entry_cooldown_seconds,
"max_entries_per_minute": settings.max_entries_per_minute,
"websocket_enabled": settings.websocket_enabled,
"min_signal_confidence": settings.min_signal_confidence,
"max_spread_percent": settings.max_spread_percent,
"min_24h_turnover_usdt": settings.min_24h_turnover_usdt,
"pattern_analysis_enabled": settings.pattern_analysis_enabled,
"pattern_score_weight": settings.pattern_score_weight,
"learning_enabled": settings.learning_enabled,
"learning_lookback_trades": settings.learning_lookback_trades,
"learning_min_samples": settings.learning_min_samples,
"learning_max_adjustment": settings.learning_max_adjustment,
"learning_max_position_multiplier": settings.learning_max_position_multiplier,
"min_position_usdt": settings.min_position_usdt,
"max_position_usdt": settings.max_position_usdt,
"max_symbol_exposure_usdt": settings.max_symbol_exposure_usdt,
"max_total_exposure_usdt": settings.max_total_exposure_usdt,
"max_open_positions": settings.max_open_positions,
"max_positions_per_symbol": settings.max_positions_per_symbol,
"grid_trading_enabled": settings.grid_trading_enabled,
"grid_entry_confidence": settings.grid_entry_confidence,
"grid_buy_zone": settings.grid_buy_zone,
"grid_max_position_usdt": settings.grid_max_position_usdt,
"rebound_trading_enabled": settings.rebound_trading_enabled,
"rebound_entry_confidence": settings.rebound_entry_confidence,
"rebound_min_probability": settings.rebound_min_probability,
"rebound_max_position_usdt": settings.rebound_max_position_usdt,
"kelly_sizing_enabled": settings.kelly_sizing_enabled,
"kelly_fraction": settings.kelly_fraction,
"kelly_max_fraction": settings.kelly_max_fraction,
"risk_per_trade_percent": settings.risk_per_trade_percent,
"risk_guard_enabled": settings.risk_guard_enabled,
"risk_recent_trade_window": settings.risk_recent_trade_window,
"risk_max_consecutive_losses": settings.risk_max_consecutive_losses,
"risk_min_recent_profit_factor": settings.risk_min_recent_profit_factor,
"risk_reduce_multiplier": settings.risk_reduce_multiplier,
"atr_trailing_multiplier": settings.atr_trailing_multiplier,
"trend_rsi_min": settings.trend_rsi_min,
"trend_rsi_max": settings.trend_rsi_max,
"time_series_forecast_enabled": settings.time_series_forecast_enabled,
"time_series_min_candles": settings.time_series_min_candles,
"time_series_forecast_horizon": settings.time_series_forecast_horizon,
"time_series_min_edge_percent": settings.time_series_min_edge_percent,
"time_series_min_probability_up": settings.time_series_min_probability_up,
"time_series_min_confidence": settings.time_series_min_confidence,
"time_series_max_adjustment": settings.time_series_max_adjustment,
"time_series_lstm_enabled": settings.time_series_lstm_enabled,
"time_series_lstm_model_path": str(settings.time_series_lstm_model_path),
"time_series_probe_enabled": settings.time_series_probe_enabled,
"time_series_probe_min_edge_percent": settings.time_series_probe_min_edge_percent,
"time_series_probe_min_probability_up": settings.time_series_probe_min_probability_up,
"time_series_probe_size_multiplier": settings.time_series_probe_size_multiplier,
"time_series_model_artifact": _time_series_model_artifact(settings),
"stop_loss_percent": settings.stop_loss_percent,
"take_profit_percent": settings.take_profit_percent,
"trailing_stop_percent": settings.trailing_stop_percent,
"min_hold_seconds": settings.min_hold_seconds,
"entry_cooldown_seconds": settings.entry_cooldown_seconds,
"max_daily_drawdown_usdt": settings.max_daily_drawdown_usdt,
"min_cash_reserve_usdt": settings.min_cash_reserve_usdt,
"taker_fee_rate": settings.taker_fee_rate,
"slippage_rate": settings.slippage_rate,
"live_ready": settings.live_ready,
"live_order_max_usdt": settings.live_order_max_usdt,
}
def _runtime_json(settings: Settings, name: str) -> dict[str, Any]:
path = settings.time_series_lstm_model_path.parent / name
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {"available": False, "path": str(path)}
if not isinstance(data, dict):
return {"available": False, "path": str(path)}
data["available"] = True
data["path"] = str(path)
return data
def _time_series_model_artifact(settings: Settings) -> dict[str, Any]:
path = settings.time_series_lstm_model_path
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {
"available": False,
"type": "missing",
"label": "нет файла модели",
"symbol_count": 0,
"models": [],
}
if not isinstance(data, dict):
return {
"available": False,
"type": "invalid",
"label": "файл модели не распознан",
"symbol_count": 0,
"models": [],
}
artifact_type = str(data.get("type", "")).strip()
symbols = data.get("symbols")
rows = list(symbols.values()) if isinstance(symbols, dict) else []
models = sorted(
{
_forecast_model_label(
str(row.get("model", row.get("architecture", "lstm"))),
torch_artifact=artifact_type == "pytorch_recurrent_forecaster",
)
for row in rows
if isinstance(row, dict)
}
)
if artifact_type != "pytorch_recurrent_forecaster":
return {
"available": False,
"type": artifact_type or "unknown",
"label": "устаревший файл модели не используется",
"created_at": data.get("created_at", ""),
"symbol_count": len(rows),
"models": models,
}
return {
"available": True,
"type": artifact_type,
"label": "PyTorch LSTM/GRU",
"created_at": data.get("created_at", ""),
"symbol_count": len(rows),
"models": models,
"feature_count": _artifact_feature_count(data, rows),
"target_horizon": _artifact_target_horizon(data, rows),
"direct_horizon": _artifact_direct_horizon(data, rows),
}
def _artifact_feature_count(data: dict[str, Any], rows: list[Any]) -> int:
feature_count = data.get("feature_count")
if isinstance(feature_count, int):
return feature_count
counts = [
int(row.get("input_size", 0))
for row in rows
if isinstance(row, dict) and isinstance(row.get("input_size"), int)
]
return max(counts) if counts else 1
def _artifact_target_horizon(data: dict[str, Any], rows: list[Any]) -> int:
horizon = data.get("target_horizon")
if isinstance(horizon, int):
return horizon
horizons = [
int(row.get("target_horizon", 0))
for row in rows
if isinstance(row, dict) and isinstance(row.get("target_horizon"), int)
]
return max(horizons) if horizons else 0
def _artifact_direct_horizon(data: dict[str, Any], rows: list[Any]) -> bool:
if bool(data.get("direct_horizon")):
return True
return any(isinstance(row, dict) and bool(row.get("direct_horizon")) for row in rows)
def _forecast_model_label(model: str, *, torch_artifact: bool = False) -> str:
normalized = model.strip().lower()
if normalized in {"torch_lstm", "lstm"} and torch_artifact:
return "PyTorch LSTM"
if normalized in {"torch_gru", "gru"} and torch_artifact:
return "PyTorch GRU"
if normalized == "lstm":
return "устаревший артефакт"
if normalized == "gru":
return "устаревший артефакт"
return model
HTML = r"""
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>TradeBot Control</title>
<style>
:root {
--bg: #f4f6f8;
--surface: #ffffff;
--surface-2: #eef2f5;
--line: #d7dee6;
--text: #17202a;
--muted: #607080;
--strong: #0f766e;
--blue: #2563eb;
--amber: #b7791f;
--red: #c24141;
--green: #138a55;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
letter-spacing: 0;
}
header {
position: sticky;
top: 0;
z-index: 20;
background: rgba(244, 246, 248, 0.96);
border-bottom: 1px solid var(--line);
backdrop-filter: blur(10px);
}
.topbar {
max-width: 1440px;
margin: 0 auto;
padding: 14px 18px;
display: grid;
grid-template-columns: minmax(180px, 1fr) auto;
gap: 14px;
align-items: center;
}
h1 { margin: 0; font-size: 20px; line-height: 1.2; }
.sub { color: var(--muted); font-size: 13px; margin-top: 3px; }
.actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; justify-content: flex-end; }
button, .toggle {
border: 1px solid var(--line);
background: var(--surface);
color: var(--text);
height: 34px;
padding: 0 12px;
border-radius: 6px;
cursor: pointer;
font-weight: 650;
font-size: 13px;
}
button.primary { background: var(--strong); color: white; border-color: var(--strong); }
button.danger { color: var(--red); }
.toggle { display: inline-flex; align-items: center; gap: 8px; }
.toggle input { margin: 0; }
nav {
max-width: 1440px;
margin: 0 auto;
padding: 0 18px 12px;
display: flex;
gap: 6px;
overflow-x: auto;
}
.tab {
min-width: max-content;
height: 32px;
border-radius: 6px;
border: 1px solid transparent;
background: transparent;
color: var(--muted);
}
.tab.active { background: var(--surface); color: var(--text); border-color: var(--line); }
main {
max-width: 1440px;
margin: 0 auto;
padding: 18px;
}
.grid { display: grid; gap: 14px; }
.cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
section.panel {
background: var(--surface);
border: 1px solid var(--line);
border-radius: 8px;
overflow: hidden;
}
.panel-head {
padding: 12px 14px;
border-bottom: 1px solid var(--line);
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.panel-head h2 { margin: 0; font-size: 15px; }
.panel-body { padding: 14px; }
.metric {
background: var(--surface);
border: 1px solid var(--line);
border-radius: 8px;
padding: 12px;
min-height: 82px;
}
.metric .label { color: var(--muted); font-size: 12px; }
.metric .value { font-size: 22px; font-weight: 750; margin-top: 6px; overflow-wrap: anywhere; }
.metric .note { color: var(--muted); font-size: 12px; margin-top: 4px; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th, td { padding: 9px 8px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; }
th { color: var(--muted); font-weight: 700; background: var(--surface-2); }
tr:last-child td { border-bottom: 0; }
.mono { font-variant-numeric: tabular-nums; }
.positive { color: var(--green); font-weight: 700; }
.negative { color: var(--red); font-weight: 700; }
.muted { color: var(--muted); }
.pill {
display: inline-flex;
align-items: center;
min-height: 24px;
padding: 3px 8px;
border-radius: 999px;
border: 1px solid var(--line);
background: var(--surface-2);
color: var(--text);
font-size: 12px;
font-weight: 700;
white-space: nowrap;
}
.pill.ok { color: var(--green); border-color: rgba(19, 138, 85, .35); background: #edf8f2; }
.pill.warn { color: var(--amber); border-color: rgba(183, 121, 31, .35); background: #fff7e6; }
.pill.error { color: var(--red); border-color: rgba(194, 65, 65, .35); background: #fff0f0; }
.stack { display: flex; flex-direction: column; gap: 8px; }
.row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
.kv { display: grid; grid-template-columns: 180px minmax(0, 1fr); gap: 8px; font-size: 13px; padding: 6px 0; border-bottom: 1px solid var(--line); }
.kv:last-child { border-bottom: 0; }
.kv span:first-child { color: var(--muted); }
.feature-list { display: grid; gap: 6px; }
.feature {
display: grid;
grid-template-columns: minmax(130px, 1fr) 90px 90px;
gap: 8px;
align-items: center;
padding: 7px 8px;
border: 1px solid var(--line);
border-radius: 6px;
background: #fbfcfd;
font-size: 12px;
}
.hidden { display: none; }
.empty { color: var(--muted); padding: 18px; text-align: center; }
@media (max-width: 980px) {
.topbar { grid-template-columns: 1fr; }
.actions { justify-content: flex-start; }
.cols-2, .cols-3, .cols-4 { grid-template-columns: 1fr; }
.kv { grid-template-columns: 1fr; }
.feature { grid-template-columns: 1fr 80px 80px; }
th, td { padding: 8px 6px; }
}
</style>
</head>
<body>
<header>
<div class="topbar">
<div>
<h1>TradeBot</h1>
<div class="sub" id="headline">Загрузка</div>
</div>
<div class="actions">
<span id="healthPill" class="pill">health</span>
<label class="toggle"><input id="fastToggle" type="checkbox" /> Fast</label>
<button id="startBtn" class="primary">Start</button>
<button id="stopBtn" class="danger">Stop</button>
</div>
</div>
<nav id="tabs"></nav>
</header>
<main id="app"></main>
<script>
const TABS = [
['overview', 'Overview'],
['markets', 'Markets / Torch'],
['risk', 'Risk / Quality'],
['pnl', 'PnL'],
['backtest', 'Backtest / Retrain'],
['logs', 'Logs']
];
const state = { tab: 'overview', data: {} };
function initTabs() {
const nav = document.getElementById('tabs');
nav.innerHTML = TABS.map(([id, label]) => `<button class="tab ${id === state.tab ? 'active' : ''}" data-tab="${id}">${label}</button>`).join('');
nav.querySelectorAll('.tab').forEach(button => {
button.addEventListener('click', () => {
state.tab = button.dataset.tab;
initTabs();
render();
});
});
}
async function fetchJson(url, options) {
const response = await fetch(url, options);
if (!response.ok) throw new Error(`${url}: ${response.status}`);
return response.json();
}
async function refresh() {
const [health, status, markets, trades, signals, events, config, analytics, reconciliation, backtest, retrain] = await Promise.all([
fetchJson('/api/health'),
fetchJson('/api/status'),
fetchJson('/api/markets'),
fetchJson('/api/trades?limit=120'),
fetchJson('/api/signals?limit=160'),
fetchJson('/api/events?limit=120'),
fetchJson('/api/config'),
fetchJson('/api/analytics'),
fetchJson('/api/reconciliation'),
fetchJson('/api/backtest'),
fetchJson('/api/retrain')
]);
state.data = { health, status, markets, trades, signals, events, config, analytics, reconciliation, backtest, retrain };
renderChrome();
render();
}
function renderChrome() {
const { health, status, config } = state.data;
const botStatus = status?.status || {};
const account = status?.account || {};
document.getElementById('headline').textContent =
`${botStatus.mode || health?.mode || 'paper'} · ${botStatus.running ? 'running' : 'stopped'} · equity ${money(account.equity)} · ${config?.symbols?.join(', ') || ''}`;
const pill = document.getElementById('healthPill');
pill.textContent = health?.ok ? 'OK' : 'ERROR';
pill.className = `pill ${health?.ok ? 'ok' : 'error'}`;
document.getElementById('fastToggle').checked = Boolean(config?.fast_trading_enabled);
}
function render() {
const root = document.getElementById('app');
const tab = state.tab;
if (tab === 'overview') root.innerHTML = overviewHtml();
if (tab === 'markets') root.innerHTML = marketsHtml();
if (tab === 'risk') root.innerHTML = riskHtml();
if (tab === 'pnl') root.innerHTML = pnlHtml();
if (tab === 'backtest') root.innerHTML = backtestHtml();
if (tab === 'logs') root.innerHTML = logsHtml();
drawCanvases();
}
function overviewHtml() {
const { status, config, analytics } = state.data;
const account = status?.account || {};
const bot = status?.status || {};
const risk = analytics?.risk_guard || {};
const artifact = config?.time_series_model_artifact || {};
return `
<div class="grid cols-4">
${metric('Equity', money(account.equity), signed(account.net_pnl_percent, 3) + '% net')}
${metric('Cash', money(account.cash), `exposure ${money(account.exposure)}`)}
${metric('Open positions', String(status?.positions?.length || 0), `drawdown ${money(account.drawdown)}`)}
${metric('Risk guard', risk.enabled ? (risk.block_new_entries ? 'BLOCK' : `${num((risk.position_size_multiplier ?? 1) * 100, 0)}% size`) : 'off', (risk.reasons || []).join(', ') || 'normal')}
</div>
<div class="grid cols-2" style="margin-top:14px">
${panel('Runtime', kvTable([
['Mode', bot.mode || ''],
['Running', bot.running ? 'yes' : 'no'],
['Message', bot.message || ''],
['Last loop', dt(bot.last_loop_at)],
['Fast loop', config?.fast_trading_enabled ? `${num(config.effective_loop_interval_seconds, 2)}s` : 'off'],
['Strategy', config?.strategy_mode || '']
]))}
${panel('Torch model', kvTable([
['Artifact', artifact.label || artifact.type || ''],
['Created', dt(artifact.created_at)],
['Models', (artifact.models || []).join(', ')],
['Symbols', String(artifact.symbol_count ?? 0)],
['Features', String(artifact.feature_count ?? '')],
['Horizon', String(artifact.target_horizon ?? '')],
['Min edge', `${num(config?.time_series_min_edge_percent, 3)}%`],
['Min P(up)', `${num((config?.time_series_min_probability_up || 0) * 100, 1)}%`],
['Min confidence', num(config?.time_series_min_confidence, 3)],
['Probe entry', config?.time_series_probe_enabled ? `${num(config?.time_series_probe_min_edge_percent, 3)}% / P ${num((config?.time_series_probe_min_probability_up || 0) * 100, 1)}% / size ${num((config?.time_series_probe_size_multiplier || 0) * 100, 0)}%` : 'off']
]))}
</div>
${positionsPanel()}
`;
}
function marketsHtml() {
const markets = state.data.markets?.markets || [];
const latestSignals = latestSignalBySymbol();
if (!markets.length) return empty('Нет market data');
return `<div class="grid">${markets.map(market => marketPanel(market, latestSignals)).join('')}</div>`;
}
function marketPanel(market, latestSignals) {
const ticker = market.ticker || {};
const symbol = ticker.symbol || market.instrument?.symbol || '';
const forecast = market.forecast || {};
const quality = market.quality || {};
const signal = latestSignals[symbol] || {};
const minEdge = state.data.config?.time_series_min_edge_percent ?? 0;
const probeEnabled = Boolean(state.data.config?.time_series_probe_enabled);
const probeEdge = state.data.config?.time_series_probe_min_edge_percent ?? 0;
const probeProbability = state.data.config?.time_series_probe_min_probability_up ?? 0;
const minConfidence = state.data.config?.time_series_min_confidence ?? 0;
const diagnostics = parseDiagnostics(signal);
const failed = Object.entries(diagnostics.checks || {}).filter(([, ok]) => !ok).map(([key]) => key);
return panel(
`<span>${escapeHtml(symbol)}</span><span class="pill ${qualityClass(quality.status)}">${quality.status || 'n/a'} ${num((quality.score || 0) * 100, 0)}%</span>`,
`<div class="grid cols-3">
${kvTable([
['Price', money(ticker.last_price, 6)],
['24h', signed(ticker.change_24h, 2) + '%'],
['Spread', num(ticker.spread_percent, 4) + '%'],
['Signal', `${signal.action || ''} ${num(signal.confidence, 4)}`],
['Reason', signal.reason || '']
])}
${kvTable([
['Model', modelName(forecast.model)],
['Edge', `${signed(forecast.expected_return_percent, 4)}% / min ${num(minEdge, 3)}%`],
['P(up)', num((forecast.probability_up || 0) * 100, 2) + '%'],
['Probe', probeEnabled ? `${num(probeEdge, 3)}% / P ${num(probeProbability * 100, 1)}% / ${diagnostics.edge_mode || 'n/a'}` : 'off'],
['Confidence', `${num(signal.confidence, 4)} / min ${num(minConfidence, 2)}`],
['Q10/Q50/Q90', `${signed(forecast.quantile_10_percent, 2)} / ${signed(forecast.quantile_50_percent, 2)} / ${signed(forecast.quantile_90_percent, 2)}`],
['Blocked', forecast.block_entry ? 'yes' : 'no']
])}
<div>
<canvas id="chart-${escapeAttr(symbol)}" height="120"></canvas>
<div class="row" style="margin-top:8px">${failed.map(item => `<span class="pill warn">${escapeHtml(item)}</span>`).join('') || '<span class="pill ok">checks ok</span>'}</div>
</div>
</div>
<div style="margin-top:12px">${featuresHtml(forecast)}</div>`
);
}
function riskHtml() {
const { analytics, markets, reconciliation } = state.data;
const risk = analytics?.risk_guard || {};
const drift = analytics?.drift || {};
const quality = markets?.quality || {};
return `
<div class="grid cols-3">
${metric('Risk guard', risk.enabled ? (risk.block_new_entries ? 'BLOCK' : 'ACTIVE') : 'OFF', (risk.reasons || []).join(', ') || 'normal')}
${metric('Drift', drift.status || 'n/a', (drift.warnings || []).join(', ') || 'normal')}
${metric('Data quality', `${num((quality.score || 0) * 100, 0)}%`, quality.status || '')}
</div>
<div class="grid cols-2" style="margin-top:14px">
${panel('Risk guard', kvTable([
['Block entries', risk.block_new_entries ? 'yes' : 'no'],
['Size multiplier', num(risk.position_size_multiplier, 4)],
['Blocked symbols', (risk.blocked_symbols || []).join(', ') || 'none'],
['Consecutive losses', String(risk.consecutive_losses ?? 0)],
['Today PnL', money(risk.today_pnl)],
['Recent PF', num(risk.recent?.profit_factor, 3)],
['Recent avg', signed(risk.recent?.avg_net_percent, 4) + '%']
]))}
${panel('Reconciliation', kvTable([
['Status', reconciliation?.status || ''],
['Live ready', reconciliation?.live_ready ? 'yes' : 'no'],
['Discrepancies', String(reconciliation?.discrepancies?.length || 0)],
['Remote equity', money(reconciliation?.account?.total_equity)]
]) + discrepanciesHtml(reconciliation?.discrepancies || []))}
</div>
${panel('Risk by symbol', symbolRiskTable(risk.symbols || []))}
${panel('Data quality by symbol', qualityTable(quality.symbols || []))}
${panel('Probability calibration', calibrationTable(analytics?.probability_calibration?.buckets || []))}
${panel('Failed checks', simpleTable(Object.entries(drift.failed_checks || {}).map(([key, value]) => ({ check: key, count: value })), ['check', 'count']))}
`;
}
function pnlHtml() {
const pnl = state.data.analytics?.pnl || {};
return `
<div class="grid cols-4">
${metric('Closed trades', String(pnl.total?.trades || 0), `${num((pnl.total?.win_rate || 0) * 100, 1)}% win`)}
${metric('Net PnL', money(pnl.total?.net_pnl), `${signed(pnl.total?.avg_net_percent, 4)}% avg`)}
${metric('Fees', money(pnl.total?.fees), `PF ${num(pnl.total?.profit_factor, 3)}`)}
${metric('Worst / Best', `${money(pnl.total?.worst)} / ${money(pnl.total?.best)}`, '')}
</div>
<div class="grid cols-3" style="margin-top:14px">
${panel('By symbol', statsTable(pnl.by_symbol || []))}
${panel('By exit', statsTable(pnl.by_exit || []))}
${panel('By model', statsTable(pnl.by_model || []))}
</div>
${panel('Recent closed trades', simpleTable((pnl.recent || []).map(row => ({
id: row.id,
symbol: row.symbol,
net: money(row.net_pnl),
net_percent: signed(row.net_percent, 3) + '%',
p_up: row.entry_probability == null ? '' : num(row.entry_probability * 100, 2) + '%',
expected: row.entry_expected_percent == null ? '' : signed(row.entry_expected_percent, 3) + '%',
exit: row.exit_category,
closed: dt(row.closed_at)
})), ['id', 'symbol', 'net', 'net_percent', 'p_up', 'expected', 'exit', 'closed']))}
`;
}
function backtestHtml() {
const backtest = state.data.backtest || {};
const retrain = state.data.retrain || {};
const rec = backtest.recommended || {};
const replay = backtest.full_replay || {};
const walk = backtest.walk_forward?.summary || {};
return `
<div class="grid cols-4">
${metric('Recommended edge', `${num(rec.edge, 4)}%`, `P(up) ${num((rec.probability || 0) * 100, 1)}%`)}
${metric('Entry replay', `${replay.trades || 0} trades`, `${signed(replay.avg_net_percent, 4)}% avg · PF ${num(replay.profit_factor, 3)}`)}
${metric('Walk-forward', `${walk.trades || 0} trades`, `${signed(walk.avg_net_percent, 4)}% avg · ${walk.status || ''}`)}
${metric('Retrain guard', retrain.available ? (retrain.accepted ? 'accepted' : 'rejected') : 'no report', retrain.reason || '')}
</div>
<div class="grid cols-2" style="margin-top:14px">
${panel('Threshold calibration', kvTable([
['Edge', num(rec.edge, 4) + '%'],
['P(up)', num((rec.probability || 0) * 100, 2) + '%'],
['Confidence', num(rec.confidence, 4)],
['Trades', String(rec.trades || 0)],
['Win rate', num((rec.win_rate || 0) * 100, 2) + '%'],
['Avg net', signed(rec.average_net_percent, 4) + '%'],
['Profit factor', num(rec.profit_factor, 4)]
]))}
${panel('Full replay', kvTable([
['Trades', String(replay.trades || 0)],
['Win rate', num((replay.win_rate || 0) * 100, 2) + '%'],
['Avg net', signed(replay.avg_net_percent, 4) + '%'],
['Total net', signed(replay.total_net_percent, 4) + '%'],
['Drawdown', num(replay.max_drawdown_percent, 4) + '%'],
['Profit factor', num(replay.profit_factor, 4)]
]))}
</div>
${panel('Walk-forward folds', simpleTable((backtest.walk_forward?.folds || []).map(row => ({
fold: row.fold,
train: row.train_records,
test: row.test_records,
edge: num(row.thresholds?.edge, 3),
p_up: num((row.thresholds?.probability || 0) * 100, 1) + '%',
trades: row.test?.trades || 0,
avg: signed(row.test?.avg_net_percent, 4) + '%',
pf: num(row.test?.profit_factor, 3)
})), ['fold', 'train', 'test', 'edge', 'p_up', 'trades', 'avg', 'pf']))}
`;
}
function logsHtml() {
const signals = state.data.signals?.items || [];
const events = state.data.events?.items || [];
return `
${panel('Signals', simpleTable(signals.slice(0, 80).map(signal => ({
id: signal.id,
symbol: signal.symbol,
action: signal.action,
confidence: num(signal.confidence, 4),
reason: signal.reason,
created: dt(signal.created_at)
})), ['id', 'symbol', 'action', 'confidence', 'reason', 'created']))}
${panel('Events', simpleTable(events.slice(0, 80).map(event => ({
id: event.id,
level: event.level,
message: event.message,
created: dt(event.created_at)
})), ['id', 'level', 'message', 'created']))}
`;
}
function positionsPanel() {
const positions = state.data.status?.positions || [];
return panel('Open positions', simpleTable(positions.map(position => ({
id: position.id,
symbol: position.symbol,
qty: num(position.qty, 8),
entry: money(position.entry_price, 6),
mark: money(position.mark_price, 6),
pnl: money(position.unrealized_pnl),
pnl_percent: signed(position.unrealized_pnl_percent, 3) + '%',
opened: dt(position.opened_at)
})), ['id', 'symbol', 'qty', 'entry', 'mark', 'pnl', 'pnl_percent', 'opened']));
}
function qualityTable(rows) {
return simpleTable(rows.map(row => ({
symbol: row.symbol,
status: row.status,
score: num((row.score || 0) * 100, 1) + '%',
candles: row.candle_count,
issues: (row.issues || []).map(issue => issue.code).join(', ') || 'none'
})), ['symbol', 'status', 'score', 'candles', 'issues']);
}
function symbolRiskTable(rows) {
return simpleTable(rows.map(row => ({
symbol: row.symbol,
block: row.block_new_entries ? 'yes' : 'no',
trades: row.trades,
win: num((row.win_rate || 0) * 100, 1) + '%',
avg: signed(row.avg_net_percent, 3) + '%',
pf: num(row.profit_factor, 3),
losses: row.consecutive_losses,
reasons: (row.reasons || []).join(', ') || 'none'
})), ['symbol', 'block', 'trades', 'win', 'avg', 'pf', 'losses', 'reasons']);
}
function calibrationTable(rows) {
return simpleTable(rows.map(row => ({
bucket: row.bucket,
trades: row.trades ?? row.samples,
predicted: num((row.avg_probability || 0) * 100, 2) + '%',
actual: num((row.actual_win_rate || 0) * 100, 2) + '%',
error: signed((row.calibration_error || 0) * 100, 2) + '%',
avg: row.avg_net_percent == null ? signed(row.avg_future_net_percent, 4) + '%' : signed(row.avg_net_percent, 4) + '%'
})), ['bucket', 'trades', 'predicted', 'actual', 'error', 'avg']);
}
function statsTable(rows) {
return simpleTable(rows.map(row => ({
key: row.key,
trades: row.trades,
win: num((row.win_rate || 0) * 100, 1) + '%',
net: money(row.net_pnl),
avg: signed(row.avg_net_percent, 3) + '%',
pf: num(row.profit_factor, 3)
})), ['key', 'trades', 'win', 'net', 'avg', 'pf']);
}
function discrepanciesHtml(rows) {
if (!rows.length) return '<div class="row"><span class="pill ok">no discrepancies</span></div>';
return `<div class="row">${rows.map(row => `<span class="pill ${qualityClass(row.severity)}">${escapeHtml(row.code)} ${escapeHtml(row.coin || '')}</span>`).join('')}</div>`;
}
function featuresHtml(forecast) {
const items = (forecast.feature_snapshot || [])
.slice()
.sort((a, b) => Math.abs(Number(b.model_value || 0)) - Math.abs(Number(a.model_value || 0)))
.slice(0, 8);
if (!items.length) return '<div class="empty">Нет feature snapshot</div>';
return `<div class="feature-list">${items.map(item => `
<div class="feature">
<div><b>${escapeHtml(item.label || item.name)}</b><div class="muted">${escapeHtml(item.group || '')}</div></div>
<div class="mono">${escapeHtml(item.raw_display ?? item.raw_value ?? '')}</div>
<div class="mono ${Number(item.model_value || 0) >= 0 ? 'positive' : 'negative'}">${escapeHtml(item.model_display ?? item.model_value ?? '')}</div>
</div>`).join('')}</div>`;
}
function panel(title, body) {
return `<section class="panel"><div class="panel-head"><h2>${title}</h2></div><div class="panel-body">${body}</div></section>`;
}
function metric(label, value, note) {
return `<div class="metric"><div class="label">${escapeHtml(label)}</div><div class="value">${escapeHtml(value ?? '')}</div><div class="note">${escapeHtml(note ?? '')}</div></div>`;
}
function kvTable(rows) {
return `<div>${rows.map(([key, value]) => `<div class="kv"><span>${escapeHtml(key)}</span><span>${escapeHtml(value ?? '')}</span></div>`).join('')}</div>`;
}
function simpleTable(rows, columns) {
if (!rows.length) return '<div class="empty">Нет данных</div>';
return `<div style="overflow:auto"><table><thead><tr>${columns.map(column => `<th>${escapeHtml(column)}</th>`).join('')}</tr></thead><tbody>${rows.map(row => `<tr>${columns.map(column => `<td>${escapeHtml(row[column] ?? '')}</td>`).join('')}</tr>`).join('')}</tbody></table></div>`;
}
function latestSignalBySymbol() {
const output = {};
for (const signal of state.data.signals?.items || []) {
if (signal.symbol && !output[signal.symbol]) output[signal.symbol] = signal;
}
return output;
}
function parseDiagnostics(signal) {
if (!signal?.diagnostics_json) return {};
try { return JSON.parse(signal.diagnostics_json); } catch { return {}; }
}
function drawCanvases() {
for (const market of state.data.markets?.markets || []) {
const symbol = market.ticker?.symbol || market.instrument?.symbol || '';
const canvas = document.getElementById(`chart-${symbol}`);
if (!canvas) continue;
drawChart(canvas, market.candles || []);
}
}
function drawChart(canvas, candles) {
const ctx = canvas.getContext('2d');
const width = canvas.clientWidth || 320;
const height = canvas.height;
canvas.width = width * devicePixelRatio;
canvas.height = height * devicePixelRatio;
ctx.scale(devicePixelRatio, devicePixelRatio);
ctx.clearRect(0, 0, width, height);
const rows = candles.slice(-80);
if (rows.length < 2) return;
const highs = rows.map(row => Number(row.high));
const lows = rows.map(row => Number(row.low));
const min = Math.min(...lows);
const max = Math.max(...highs);
const range = Math.max(1e-9, max - min);
ctx.lineWidth = 1.4;
rows.forEach((row, index) => {
const x = 6 + index * ((width - 12) / Math.max(1, rows.length - 1));
const yOpen = height - 6 - ((row.open - min) / range) * (height - 12);
const yClose = height - 6 - ((row.close - min) / range) * (height - 12);
const yHigh = height - 6 - ((row.high - min) / range) * (height - 12);
const yLow = height - 6 - ((row.low - min) / range) * (height - 12);
ctx.strokeStyle = Number(row.close) >= Number(row.open) ? '#138a55' : '#c24141';
ctx.beginPath();
ctx.moveTo(x, yHigh);
ctx.lineTo(x, yLow);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(x - 2, yOpen);
ctx.lineTo(x + 2, yClose);
ctx.stroke();
});
}
function modelName(value) {
if (value === 'torch_lstm') return 'PyTorch LSTM';
if (value === 'torch_gru') return 'PyTorch GRU';
return value || '';
}
function qualityClass(value) {
if (value === 'ok') return 'ok';
if (value === 'error') return 'error';
return 'warn';
}
function money(value, digits = 2) {
const n = Number(value);
if (!Number.isFinite(n)) return '';
return `${n.toFixed(digits)} USDT`;
}
function num(value, digits = 2) {
const n = Number(value);
if (!Number.isFinite(n)) return '';
return n.toFixed(digits);
}
function signed(value, digits = 2) {
const n = Number(value);
if (!Number.isFinite(n)) return '';
return `${n >= 0 ? '+' : ''}${n.toFixed(digits)}`;
}
function dt(value) {
if (!value) return '';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return String(value);
return date.toLocaleString('ru-RU', { hour12: false });
}
function empty(text) { return `<div class="empty">${escapeHtml(text)}</div>`; }
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>"']/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[char]));
}
function escapeAttr(value) { return escapeHtml(value).replace(/[^A-Za-z0-9_-]/g, ''); }
document.getElementById('startBtn').addEventListener('click', async () => { await fetchJson('/api/control/start', { method: 'POST' }); await refresh(); });
document.getElementById('stopBtn').addEventListener('click', async () => { await fetchJson('/api/control/stop', { method: 'POST' }); await refresh(); });
document.getElementById('fastToggle').addEventListener('change', async event => {
await fetchJson('/api/config/fast-trading', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: event.target.checked }) });
await refresh();
});
initTabs();
refresh().catch(error => { document.getElementById('headline').textContent = error.message; });
setInterval(() => refresh().catch(error => { document.getElementById('headline').textContent = error.message; }), 5000);
</script>
</body>
</html>
"""