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