1063 lines
45 KiB
Python
1063 lines
45 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_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)]
|
|
]))}
|
|
</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 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) + '%'],
|
|
['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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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>
|
|
"""
|