1120 lines
53 KiB
Python
1120 lines
53 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.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.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/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,
|
||
"base_interval": settings.base_interval,
|
||
"kline_limit": settings.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,
|
||
"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,
|
||
"time_series_forecast_enabled": settings.time_series_forecast_enabled,
|
||
"time_series_min_candles": settings.time_series_min_candles,
|
||
"time_series_validation_window": settings.time_series_validation_window,
|
||
"time_series_forecast_horizon": settings.time_series_forecast_horizon,
|
||
"time_series_ewma_lambda": settings.time_series_ewma_lambda,
|
||
"time_series_min_edge_percent": settings.time_series_min_edge_percent,
|
||
"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 _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": [],
|
||
}
|
||
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"))))
|
||
for row in rows
|
||
if isinstance(row, dict)
|
||
}
|
||
)
|
||
artifact_type = str(data.get("type", "")).strip()
|
||
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,
|
||
}
|
||
|
||
|
||
def _forecast_model_label(model: str) -> str:
|
||
normalized = model.strip().lower()
|
||
if normalized == "torch_lstm":
|
||
return "PyTorch LSTM"
|
||
if normalized == "torch_gru":
|
||
return "PyTorch GRU"
|
||
if normalized == "lstm":
|
||
return "устаревший LSTM"
|
||
if normalized == "gru":
|
||
return "GRU"
|
||
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>Крипто спот-бот</title>
|
||
<style>
|
||
:root {
|
||
--bg: #f5f7fb;
|
||
--panel: #ffffff;
|
||
--panel-2: #eef3f8;
|
||
--text: #111827;
|
||
--muted: #627084;
|
||
--border: #d9e1ea;
|
||
--accent: #0b7a75;
|
||
--accent-2: #2563eb;
|
||
--danger: #c2410c;
|
||
--green: #0f9f6e;
|
||
--red: #d64545;
|
||
--shadow: 0 10px 28px rgba(16, 24, 40, 0.08);
|
||
}
|
||
* { box-sizing: border-box; }
|
||
body {
|
||
margin: 0;
|
||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||
color: var(--text);
|
||
background: var(--bg);
|
||
letter-spacing: 0;
|
||
}
|
||
header {
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 5;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
gap: 18px;
|
||
padding: 16px 24px;
|
||
background: rgba(255,255,255,0.94);
|
||
border-bottom: 1px solid var(--border);
|
||
backdrop-filter: blur(14px);
|
||
}
|
||
h1 { margin: 0; font-size: 22px; line-height: 1.15; }
|
||
h2 { margin: 0 0 12px; font-size: 17px; }
|
||
.subline { margin-top: 4px; color: var(--muted); font-size: 13px; }
|
||
.header-actions { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
||
button {
|
||
border: 1px solid var(--border);
|
||
background: var(--panel);
|
||
color: var(--text);
|
||
padding: 9px 13px;
|
||
border-radius: 7px;
|
||
font-weight: 700;
|
||
cursor: pointer;
|
||
font-size: 13px;
|
||
}
|
||
button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||
button.danger { background: var(--danger); border-color: var(--danger); color: #fff; }
|
||
.switch-control {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
min-height: 34px;
|
||
padding: 4px 8px;
|
||
border: 1px solid var(--border);
|
||
border-radius: 7px;
|
||
background: var(--panel);
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
font-weight: 800;
|
||
cursor: pointer;
|
||
user-select: none;
|
||
}
|
||
.switch-control input {
|
||
position: absolute;
|
||
opacity: 0;
|
||
pointer-events: none;
|
||
}
|
||
.switch {
|
||
position: relative;
|
||
width: 42px;
|
||
height: 24px;
|
||
flex: 0 0 42px;
|
||
border-radius: 999px;
|
||
background: #cbd5e1;
|
||
transition: background 0.15s ease;
|
||
}
|
||
.switch::after {
|
||
content: "";
|
||
position: absolute;
|
||
top: 3px;
|
||
left: 3px;
|
||
width: 18px;
|
||
height: 18px;
|
||
border-radius: 50%;
|
||
background: #fff;
|
||
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.25);
|
||
transition: transform 0.15s ease;
|
||
}
|
||
.switch-control input:checked + .switch { background: var(--accent); }
|
||
.switch-control input:checked + .switch::after { transform: translateX(18px); }
|
||
.switch-control input:focus-visible + .switch { outline: 2px solid var(--accent-2); outline-offset: 2px; }
|
||
.switch-control input:disabled + .switch { opacity: 0.65; }
|
||
.badge {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
min-height: 28px;
|
||
padding: 5px 9px;
|
||
border-radius: 7px;
|
||
background: var(--panel-2);
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
font-weight: 800;
|
||
}
|
||
.badge.ok { color: #075e42; background: #daf5ea; }
|
||
.badge.warn { color: #8a3b12; background: #ffedd5; }
|
||
main { padding: 18px 24px 30px; }
|
||
.grid { display: grid; gap: 14px; }
|
||
.stats { grid-template-columns: repeat(6, minmax(130px, 1fr)); margin-bottom: 16px; }
|
||
.stat, .panel, .market-card {
|
||
background: var(--panel);
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
box-shadow: var(--shadow);
|
||
}
|
||
.stat { padding: 13px 14px; min-height: 82px; }
|
||
.label { color: var(--muted); font-size: 12px; font-weight: 800; text-transform: uppercase; }
|
||
.value { margin-top: 8px; font-size: 22px; font-weight: 850; white-space: nowrap; }
|
||
.markets { grid-template-columns: repeat(3, minmax(260px, 1fr)); margin-bottom: 16px; }
|
||
.market-card { padding: 13px; }
|
||
.market-top { display: flex; align-items: start; justify-content: space-between; gap: 10px; margin-bottom: 8px; }
|
||
.symbol { font-size: 16px; font-weight: 850; }
|
||
.price { font-size: 14px; font-weight: 800; color: var(--accent-2); text-align: right; }
|
||
canvas { width: 100%; height: 170px; background: #fbfdff; border: 1px solid var(--border); border-radius: 6px; display: block; }
|
||
.indicators {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||
gap: 8px;
|
||
margin-top: 10px;
|
||
font-size: 12px;
|
||
}
|
||
.indicator { padding: 7px 8px; background: var(--panel-2); border-radius: 6px; min-width: 0; }
|
||
.indicator b { display: block; font-size: 11px; color: var(--muted); margin-bottom: 3px; }
|
||
.pattern-line {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 10px;
|
||
margin: 8px 0 9px;
|
||
padding: 8px 9px;
|
||
background: #f8fafc;
|
||
border: 1px solid var(--border);
|
||
border-radius: 6px;
|
||
font-size: 12px;
|
||
}
|
||
.pattern-line strong { color: var(--accent); }
|
||
.forecast-line {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||
gap: 6px;
|
||
margin: -2px 0 9px;
|
||
font-size: 11px;
|
||
}
|
||
.forecast-chip {
|
||
min-width: 0;
|
||
padding: 6px 7px;
|
||
border: 1px solid var(--border);
|
||
border-radius: 6px;
|
||
background: #ffffff;
|
||
}
|
||
.forecast-chip b { display: block; color: var(--muted); font-size: 10px; margin-bottom: 2px; }
|
||
.layout { grid-template-columns: 1.2fr 0.8fr; align-items: start; }
|
||
.panel { padding: 14px; min-width: 0; }
|
||
.table-wrap { overflow: auto; max-height: 330px; border: 1px solid var(--border); border-radius: 7px; }
|
||
table { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 13px; }
|
||
th, td { padding: 9px 10px; border-bottom: 1px solid var(--border); text-align: left; white-space: nowrap; }
|
||
th {
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 2;
|
||
background: #f8fafc;
|
||
color: var(--muted);
|
||
font-size: 11px;
|
||
text-transform: uppercase;
|
||
}
|
||
tr:last-child td { border-bottom: none; }
|
||
.positive { color: var(--green); font-weight: 800; }
|
||
.negative { color: var(--red); font-weight: 800; }
|
||
.stack { display: grid; gap: 14px; }
|
||
.config-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; font-size: 13px; }
|
||
.config-item { display: flex; justify-content: space-between; gap: 8px; border-bottom: 1px solid var(--border); padding: 7px 0; }
|
||
.learning-list { display: grid; gap: 8px; font-size: 13px; }
|
||
.learning-row { display: flex; justify-content: space-between; gap: 10px; border-bottom: 1px solid var(--border); padding: 7px 0; }
|
||
.learning-row span { color: var(--muted); }
|
||
.learning-state-line {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 10px;
|
||
padding: 0 0 10px;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
.state-pill {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
min-height: 26px;
|
||
padding: 4px 8px;
|
||
border-radius: 6px;
|
||
font-size: 12px;
|
||
font-weight: 850;
|
||
white-space: nowrap;
|
||
}
|
||
.state-pill.ok { color: #075e42; background: #daf5ea; }
|
||
.state-pill.warn { color: #8a3b12; background: #ffedd5; }
|
||
.state-pill.bad { color: #991b1b; background: #fee2e2; }
|
||
.learning-metrics {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 8px 12px;
|
||
}
|
||
.learning-metric {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 8px;
|
||
padding: 7px 0;
|
||
border-bottom: 1px solid var(--border);
|
||
min-width: 0;
|
||
}
|
||
.learning-metric span { color: var(--muted); }
|
||
.learning-metric strong { text-align: right; }
|
||
.learning-section-title {
|
||
margin-top: 6px;
|
||
color: var(--muted);
|
||
font-size: 11px;
|
||
font-weight: 850;
|
||
text-transform: uppercase;
|
||
}
|
||
.effect-list { display: grid; gap: 6px; }
|
||
.effect-row {
|
||
display: grid;
|
||
grid-template-columns: 72px 1fr auto;
|
||
gap: 8px;
|
||
align-items: center;
|
||
padding: 6px 0;
|
||
border-bottom: 1px solid var(--border);
|
||
min-width: 0;
|
||
}
|
||
.effect-row .small { color: var(--muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.reason-cell { max-width: 520px; min-width: 240px; white-space: normal; }
|
||
.muted { color: var(--muted); }
|
||
@media (max-width: 1180px) {
|
||
.stats { grid-template-columns: repeat(3, minmax(130px, 1fr)); }
|
||
.markets { grid-template-columns: repeat(2, minmax(260px, 1fr)); }
|
||
.layout { grid-template-columns: 1fr; }
|
||
}
|
||
@media (max-width: 720px) {
|
||
header { position: static; align-items: flex-start; padding: 14px; flex-direction: column; }
|
||
main { padding: 14px; }
|
||
.stats, .markets { grid-template-columns: 1fr; }
|
||
.value { font-size: 20px; }
|
||
.config-grid { grid-template-columns: 1fr; }
|
||
.learning-metrics { grid-template-columns: 1fr; }
|
||
.effect-row { grid-template-columns: 62px 1fr; }
|
||
.effect-row strong { text-align: left; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header>
|
||
<div>
|
||
<h1>Крипто спот-бот</h1>
|
||
<div class="subline" id="subline">Загрузка состояния...</div>
|
||
</div>
|
||
<div class="header-actions">
|
||
<span class="badge" id="modeBadge">Демо</span>
|
||
<span class="badge" id="liveBadge">Реальная торговля заблокирована</span>
|
||
<label class="switch-control" title="Переключает быстрый цикл принятия решений">
|
||
<input type="checkbox" id="fastToggle" data-testid="fast-toggle" />
|
||
<span class="switch" aria-hidden="true"></span>
|
||
<span id="fastToggleLabel">Быстрая торговля</span>
|
||
</label>
|
||
<span class="badge" id="fastBadge">Обычный режим</span>
|
||
<span class="badge" id="wsBadge">Поток данных</span>
|
||
<button class="primary" id="startBtn">Старт</button>
|
||
<button class="danger" id="stopBtn">Стоп</button>
|
||
</div>
|
||
</header>
|
||
<main>
|
||
<section class="grid stats">
|
||
<div class="stat"><div class="label">Баланс</div><div class="value" id="equity">-</div></div>
|
||
<div class="stat"><div class="label">Свободно</div><div class="value" id="cash">-</div></div>
|
||
<div class="stat"><div class="label">В позициях</div><div class="value" id="exposure">-</div></div>
|
||
<div class="stat"><div class="label">Прибыль/убыток</div><div class="value" id="pnl">-</div></div>
|
||
<div class="stat"><div class="label">Позиции</div><div class="value" id="positionsCount">-</div></div>
|
||
<div class="stat"><div class="label">Просадка</div><div class="value" id="drawdown">-</div></div>
|
||
</section>
|
||
<section class="grid markets" id="markets"></section>
|
||
<section class="grid layout">
|
||
<div class="stack">
|
||
<div class="panel">
|
||
<h2>Открытые позиции</h2>
|
||
<div class="table-wrap"><table><thead><tr><th>Пара</th><th>Кол-во</th><th>Вход</th><th>Цена</th><th>Прибыль/убыток</th><th>Стоп</th><th>Цель</th></tr></thead><tbody id="positionsTable"></tbody></table></div>
|
||
</div>
|
||
<div class="panel">
|
||
<h2>Сделки</h2>
|
||
<div class="table-wrap"><table><thead><tr><th>Время</th><th>Пара</th><th>Сторона</th><th>Кол-во</th><th>Вход</th><th>Выход</th><th>Итог</th><th>Причина</th></tr></thead><tbody id="tradesTable"></tbody></table></div>
|
||
</div>
|
||
<div class="panel">
|
||
<h2>Сигналы стратегии</h2>
|
||
<div class="table-wrap"><table><thead><tr><th>Время</th><th>Пара</th><th>Действие</th><th>Режим</th><th>Размер</th><th>Уверенность</th><th>База</th><th>Шаблон</th><th>Обучение</th><th>Прогноз</th><th>Отскок</th><th>Итог</th><th>Причина</th></tr></thead><tbody id="signalsTable"></tbody></table></div>
|
||
</div>
|
||
</div>
|
||
<aside class="stack">
|
||
<div class="panel">
|
||
<h2>Параметры</h2>
|
||
<div class="config-grid" id="configGrid"></div>
|
||
</div>
|
||
<div class="panel">
|
||
<h2>Обучение на сделках</h2>
|
||
<div class="learning-list" id="learningPanel"></div>
|
||
</div>
|
||
<div class="panel">
|
||
<h2>События</h2>
|
||
<div class="table-wrap"><table><thead><tr><th>Время</th><th>Уровень</th><th>Сообщение</th></tr></thead><tbody id="eventsTable"></tbody></table></div>
|
||
</div>
|
||
</aside>
|
||
</section>
|
||
</main>
|
||
<script>
|
||
const fmt = new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 4 });
|
||
const money = (value) => Number.isFinite(Number(value)) ? `${fmt.format(Number(value))} USDT` : '-';
|
||
const num = (value, digits = 4) => Number.isFinite(Number(value)) ? Number(value).toFixed(digits) : '-';
|
||
const time = (value) => value ? new Date(value).toLocaleString('ru-RU') : '-';
|
||
const modeName = (value) => ({ paper: 'Демо', live: 'Реальная торговля' }[value] || value || '-');
|
||
const actionName = (value) => ({ BUY: 'Купить', SELL: 'Продать', HOLD: 'Ждать' }[value] || value || '-');
|
||
const sideName = (value) => ({ BUY: 'Покупка', SELL: 'Продажа' }[value] || value || '-');
|
||
const levelName = (value) => ({ INFO: 'Инфо', WARN: 'Предупреждение', ERROR: 'Ошибка' }[value] || value || '-');
|
||
const yesNo = (value) => value ? 'Да' : 'Нет';
|
||
function messageText(value) {
|
||
return String(value || '')
|
||
.replaceAll('WebSocket Bybit подключен', 'Поток данных Bybit подключен')
|
||
.replaceAll('WebSocket Bybit отключен', 'Поток данных Bybit отключен')
|
||
.replaceAll('paper BUY', 'демо-покупка')
|
||
.replaceAll('paper SELL', 'демо-продажа')
|
||
.replaceAll('live shadow BUY', 'реальная покупка, локальная запись')
|
||
.replaceAll('live shadow SELL', 'реальная продажа, локальная запись')
|
||
.replaceAll('live BUY', 'реальная покупка')
|
||
.replaceAll('live SELL', 'реальная продажа')
|
||
.replaceAll('qty=', 'кол-во=')
|
||
.replaceAll('price=', 'цена=')
|
||
.replaceAll('conf=', 'уверенность=')
|
||
.replaceAll('net=', 'итог=')
|
||
.replaceAll('reason=', 'причина=')
|
||
.replaceAll('stop-loss', 'стоп-лосс')
|
||
.replaceAll('take-profit', 'тейк-профит')
|
||
.replaceAll('trailing stop', 'трейлинг-стоп');
|
||
}
|
||
async function fetchJson(url, options) {
|
||
const response = await fetch(url, options);
|
||
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
|
||
return response.json();
|
||
}
|
||
function setText(id, value) { document.getElementById(id).textContent = value; }
|
||
function signedClass(value) { return Number(value) >= 0 ? 'positive' : 'negative'; }
|
||
|
||
async function refresh() {
|
||
const [status, markets, trades, signals, events, config] = await Promise.all([
|
||
fetchJson('/api/status'),
|
||
fetchJson('/api/markets'),
|
||
fetchJson('/api/trades?limit=80'),
|
||
fetchJson('/api/signals?limit=120'),
|
||
fetchJson('/api/events?limit=80'),
|
||
fetchJson('/api/config')
|
||
]);
|
||
renderStatus(status, markets);
|
||
renderMarkets(markets);
|
||
renderPositions(status.positions || []);
|
||
renderTrades(trades.items || []);
|
||
renderSignals(signals.items || []);
|
||
renderEvents(events.items || []);
|
||
renderConfig(config);
|
||
renderFastMode(config);
|
||
renderLearning(status.learning || {}, signals.items || [], config);
|
||
}
|
||
|
||
function renderStatus(payload, markets) {
|
||
const s = payload.status;
|
||
const a = payload.account;
|
||
setText('subline', `${s.running ? 'Работает' : 'Остановлен'} · ${s.symbols.join(', ') || 'пары не выбраны'} · цикл ${time(s.last_loop_at)}`);
|
||
setText('equity', money(a.equity));
|
||
setText('cash', money(a.cash));
|
||
setText('exposure', money(a.exposure));
|
||
setText('pnl', `${num(a.net_pnl, 4)} (${num(a.net_pnl_percent, 2)}%)`);
|
||
document.getElementById('pnl').className = `value ${signedClass(a.net_pnl)}`;
|
||
setText('positionsCount', String((payload.positions || []).length));
|
||
setText('drawdown', money(a.drawdown));
|
||
const modeBadge = document.getElementById('modeBadge');
|
||
modeBadge.textContent = modeName(s.mode);
|
||
modeBadge.className = `badge ${s.mode === 'paper' ? 'ok' : 'warn'}`;
|
||
const liveBadge = document.getElementById('liveBadge');
|
||
liveBadge.textContent = s.live_trading_ready ? 'Реальная торговля разрешена' : 'Реальная торговля заблокирована';
|
||
liveBadge.className = `badge ${s.live_trading_ready ? 'warn' : 'ok'}`;
|
||
const wsBadge = document.getElementById('wsBadge');
|
||
wsBadge.textContent = markets.ws_connected ? 'Поток данных подключен' : 'Поток данных отключен';
|
||
wsBadge.className = `badge ${markets.ws_connected ? 'ok' : 'warn'}`;
|
||
}
|
||
|
||
function renderMarkets(payload) {
|
||
const root = document.getElementById('markets');
|
||
root.innerHTML = '';
|
||
for (const market of payload.markets || []) {
|
||
const ticker = market.ticker;
|
||
const pattern = market.pattern || {};
|
||
const last = market.candles?.[market.candles.length - 1] || {};
|
||
const card = document.createElement('article');
|
||
card.className = 'market-card';
|
||
const symbol = ticker?.symbol || market.instrument?.symbol || '-';
|
||
card.innerHTML = `
|
||
<div class="market-top">
|
||
<div><div class="symbol">${symbol}</div><div class="muted">Спред ${num(ticker?.spread_percent, 4)}%</div></div>
|
||
<div class="price">${money(ticker?.last_price)}</div>
|
||
</div>
|
||
<div class="pattern-line">
|
||
<span>Шаблон: <strong>${escapeHtml(pattern.label || 'нет данных')}</strong></span>
|
||
<span>${num(pattern.score, 2)}</span>
|
||
</div>
|
||
${forecastHtml(market.forecast || {})}
|
||
<canvas width="520" height="220"></canvas>
|
||
<div class="indicators">
|
||
<div class="indicator"><b>RSI14</b>${num(last.rsi_14, 2)}</div>
|
||
<div class="indicator"><b>EMA50</b>${num(last.ema_50, 4)}</div>
|
||
<div class="indicator"><b>EMA200</b>${num(last.ema_200, 4)}</div>
|
||
<div class="indicator"><b>ATR14</b>${num(last.atr_14, 4)}</div>
|
||
<div class="indicator"><b>Объем MA20</b>${num(last.volume_ma_20, 4)}</div>
|
||
<div class="indicator"><b>Оборот 24ч</b>${money(ticker?.turnover_24h)}</div>
|
||
</div>
|
||
`;
|
||
root.appendChild(card);
|
||
drawCandles(card.querySelector('canvas'), market.candles || []);
|
||
}
|
||
}
|
||
|
||
function modelName(model) {
|
||
const key = String(model || '').toLowerCase();
|
||
const names = {
|
||
torch_lstm: 'PyTorch LSTM',
|
||
torch_gru: 'PyTorch GRU',
|
||
lstm: 'Устаревший LSTM',
|
||
naive: 'Baseline',
|
||
drift: 'Drift',
|
||
ewma: 'EWMA',
|
||
ar1: 'AR(1)',
|
||
ar3: 'AR(3)',
|
||
none: '-'
|
||
};
|
||
return names[key] || String(model || '-');
|
||
}
|
||
|
||
function modelReason(reason) {
|
||
return String(reason || '')
|
||
.replaceAll('torch_lstm', 'PyTorch LSTM')
|
||
.replaceAll('torch_gru', 'PyTorch GRU')
|
||
.replaceAll('модель lstm', 'модель устаревший LSTM');
|
||
}
|
||
|
||
function modelArtifactSummary(config) {
|
||
if (!config.time_series_lstm_enabled) {
|
||
return 'выкл';
|
||
}
|
||
const artifact = config.time_series_model_artifact || {};
|
||
if (!artifact.available) {
|
||
return artifact.label || 'нет файла модели';
|
||
}
|
||
const parts = [artifact.label || 'модель прогноза'];
|
||
if (Number(artifact.symbol_count || 0) > 0) {
|
||
parts.push(`${artifact.symbol_count} пар`);
|
||
}
|
||
if (Array.isArray(artifact.models) && artifact.models.length) {
|
||
parts.push(artifact.models.join(', '));
|
||
}
|
||
const date = shortDateTime(artifact.created_at);
|
||
if (date) {
|
||
parts.push(date);
|
||
}
|
||
return parts.join(' · ');
|
||
}
|
||
|
||
function shortDateTime(value) {
|
||
if (!value) return '';
|
||
const date = new Date(value);
|
||
if (Number.isNaN(date.getTime())) return '';
|
||
return date.toLocaleString('ru-RU', {
|
||
day: '2-digit',
|
||
month: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit'
|
||
});
|
||
}
|
||
|
||
function forecastHtml(forecast) {
|
||
if (!forecast || !forecast.usable) {
|
||
return `<div class="forecast-line"><div class="forecast-chip"><b>Прогноз</b>${escapeHtml(modelReason(forecast?.reason || 'нет данных'))}</div></div>`;
|
||
}
|
||
return `<div class="forecast-line">
|
||
<div class="forecast-chip"><b>Модель</b>${escapeHtml(modelName(forecast.model || '-'))}</div>
|
||
<div class="forecast-chip"><b>P роста</b>${num((forecast.probability_up || 0) * 100, 1)}%</div>
|
||
<div class="forecast-chip"><b>Ожидание</b><span class="${signedClass(forecast.expected_return_percent || 0)}">${signedNum(forecast.expected_return_percent, 3)}%</span></div>
|
||
<div class="forecast-chip"><b>Волат.</b>${num(forecast.volatility_percent, 3)}%</div>
|
||
</div>`;
|
||
}
|
||
|
||
function drawCandles(canvas, candles) {
|
||
const ctx = canvas.getContext('2d');
|
||
const w = canvas.width, h = canvas.height;
|
||
ctx.clearRect(0, 0, w, h);
|
||
ctx.fillStyle = '#fbfdff';
|
||
ctx.fillRect(0, 0, w, h);
|
||
const data = candles.slice(-80);
|
||
if (data.length < 2) return;
|
||
const highs = data.map(c => c.high).concat(data.map(c => c.ema_50 || c.close), data.map(c => c.ema_200 || c.close));
|
||
const lows = data.map(c => c.low).concat(data.map(c => c.ema_50 || c.close), data.map(c => c.ema_200 || c.close));
|
||
const max = Math.max(...highs), min = Math.min(...lows);
|
||
const pad = Math.max((max - min) * 0.08, max * 0.0008);
|
||
const y = (v) => h - 18 - ((v - min + pad) / (max - min + pad * 2)) * (h - 34);
|
||
const step = w / data.length;
|
||
ctx.strokeStyle = '#e2e8f0';
|
||
ctx.lineWidth = 1;
|
||
for (let i = 0; i < 4; i++) {
|
||
const yy = 16 + i * ((h - 34) / 3);
|
||
ctx.beginPath(); ctx.moveTo(0, yy); ctx.lineTo(w, yy); ctx.stroke();
|
||
}
|
||
data.forEach((c, i) => {
|
||
const x = i * step + step / 2;
|
||
const up = c.close >= c.open;
|
||
ctx.strokeStyle = up ? '#0f9f6e' : '#d64545';
|
||
ctx.fillStyle = ctx.strokeStyle;
|
||
ctx.beginPath(); ctx.moveTo(x, y(c.high)); ctx.lineTo(x, y(c.low)); ctx.stroke();
|
||
const bodyY = Math.min(y(c.open), y(c.close));
|
||
const bodyH = Math.max(2, Math.abs(y(c.open) - y(c.close)));
|
||
ctx.fillRect(x - Math.max(2, step * 0.28), bodyY, Math.max(3, step * 0.56), bodyH);
|
||
});
|
||
drawLine(ctx, data, 'ema_50', '#2563eb', y, step);
|
||
drawLine(ctx, data, 'ema_200', '#7c3aed', y, step);
|
||
}
|
||
function drawLine(ctx, data, key, color, y, step) {
|
||
ctx.strokeStyle = color;
|
||
ctx.lineWidth = 1.5;
|
||
ctx.beginPath();
|
||
let started = false;
|
||
data.forEach((c, i) => {
|
||
if (!c[key]) return;
|
||
const x = i * step + step / 2;
|
||
if (!started) { ctx.moveTo(x, y(c[key])); started = true; }
|
||
else ctx.lineTo(x, y(c[key]));
|
||
});
|
||
if (started) ctx.stroke();
|
||
}
|
||
|
||
function renderPositions(items) {
|
||
document.getElementById('positionsTable').innerHTML = items.map(p => `
|
||
<tr><td>${p.symbol}</td><td>${num(p.qty, 8)}</td><td>${num(p.entry_price, 6)}</td><td>${num(p.mark_price, 6)}</td><td class="${signedClass(p.unrealized_pnl)}">${num(p.unrealized_pnl, 4)}</td><td>${num(p.stop_loss, 6)}</td><td>${num(p.take_profit, 6)}</td></tr>
|
||
`).join('') || `<tr><td colspan="7" class="muted">Нет открытых позиций</td></tr>`;
|
||
}
|
||
function renderTrades(items) {
|
||
document.getElementById('tradesTable').innerHTML = items.map(t => `
|
||
<tr><td>${time(t.closed_at || t.opened_at)}</td><td>${t.symbol}</td><td>${sideName(t.side)}</td><td>${num(t.qty, 8)}</td><td>${num(t.entry_price, 6)}</td><td>${num(t.exit_price, 6)}</td><td class="${signedClass(t.net_pnl)}">${num(t.net_pnl, 4)}</td><td>${escapeHtml(t.reason || '')}</td></tr>
|
||
`).join('') || `<tr><td colspan="8" class="muted">Сделок пока нет</td></tr>`;
|
||
}
|
||
function renderSignals(items) {
|
||
document.getElementById('signalsTable').innerHTML = items.map(s => `
|
||
${signalRowHtml(s)}
|
||
`).join('');
|
||
}
|
||
function signalRowHtml(signal) {
|
||
const d = parseDiagnostics(signal);
|
||
return `<tr>
|
||
<td>${time(signal.created_at)}</td>
|
||
<td>${signal.symbol}</td>
|
||
<td>${actionName(signal.action)}</td>
|
||
<td>${escapeHtml(d.trade_mode || '-')}</td>
|
||
<td>${money(d.position_notional_usdt)}</td>
|
||
<td>${num(signal.confidence, 3)}</td>
|
||
<td>${num(d.base_score, 3)}</td>
|
||
<td class="${signedClass(d.pattern_adjustment || 0)}">${signedNum(d.pattern_adjustment, 3)}</td>
|
||
<td class="${signedClass(d.learning_adjustment || 0)}">${signedNum(d.learning_adjustment, 3)}</td>
|
||
${forecastSignalCell(d.forecast || {}, d.forecast_adjustment)}
|
||
<td>${num(d.rebound_probability, 3)}</td>
|
||
<td>${num(d.final_score, 3)}</td>
|
||
<td>${escapeHtml(signal.reason || '')}</td>
|
||
</tr>`;
|
||
}
|
||
function renderEvents(items) {
|
||
document.getElementById('eventsTable').innerHTML = items.map(e => `
|
||
<tr><td>${time(e.created_at)}</td><td>${levelName(e.level)}</td><td>${escapeHtml(messageText(e.message))}</td></tr>
|
||
`).join('');
|
||
}
|
||
function forecastSignalCell(forecast, adjustment) {
|
||
if (!forecast || !forecast.model) {
|
||
return '<td>-</td>';
|
||
}
|
||
const expected = Number(forecast.expected_return_percent);
|
||
const probability = Number(forecast.probability_up);
|
||
const skill = Number(forecast.skill);
|
||
const model = escapeHtml(modelName(forecast.model || '-'));
|
||
const expectedText = Number.isFinite(expected) ? `${signedNum(expected, 3)}%` : '-';
|
||
const probabilityText = Number.isFinite(probability) ? `P${num(probability * 100, 1)}%` : 'P-';
|
||
const skillText = Number.isFinite(skill) ? `S${num(skill, 3)}` : 'S-';
|
||
const adjustmentText = Number.isFinite(Number(adjustment)) && Number(adjustment) !== 0
|
||
? ` · ${signedNum(adjustment, 3)}`
|
||
: '';
|
||
return `<td class="${signedClass(expected || 0)}">${model} ${expectedText} · ${probabilityText} · ${skillText}${adjustmentText}</td>`;
|
||
}
|
||
function renderConfig(config) {
|
||
const keys = [
|
||
['Режим', modeName(config.trading_mode)],
|
||
['Стартовый баланс', money(config.starting_balance_usdt)],
|
||
['Мин. уверенность', config.min_signal_confidence],
|
||
['Быстрая торговля', yesNo(config.fast_trading_enabled)],
|
||
['Цикл решений', `${num(config.effective_loop_interval_seconds, 2)}с`],
|
||
['Пауза входа', `${config.effective_entry_cooldown_seconds}с`],
|
||
['Новых входов/мин', config.max_entries_per_minute],
|
||
['Анализ шаблонов', yesNo(config.pattern_analysis_enabled)],
|
||
['Вес шаблонов', config.pattern_score_weight],
|
||
['Обучение', yesNo(config.learning_enabled)],
|
||
['Сделок для обучения', `${config.learning_lookback_trades}, мин. ${config.learning_min_samples}`],
|
||
['Макс. спред', `${config.max_spread_percent}%`],
|
||
['Мин. оборот 24ч', money(config.min_24h_turnover_usdt)],
|
||
['Размер позиции', `${money(config.min_position_usdt)} - ${money(config.max_position_usdt)}`],
|
||
['Лимит на пару', money(config.max_symbol_exposure_usdt)],
|
||
['Grid-режим', yesNo(config.grid_trading_enabled)],
|
||
['Grid порог / зона', `${num(config.grid_entry_confidence, 2)} / ${num((config.grid_buy_zone || 0) * 100, 0)}%`],
|
||
['Grid макс. размер', money(config.grid_max_position_usdt)],
|
||
['Rebound-режим', yesNo(config.rebound_trading_enabled)],
|
||
['Rebound порог / вероятность', `${num(config.rebound_entry_confidence, 2)} / ${num(config.rebound_min_probability, 2)}`],
|
||
['Rebound макс. размер', money(config.rebound_max_position_usdt)],
|
||
['Kelly размер', `${yesNo(config.kelly_sizing_enabled)} · ${num(config.kelly_fraction, 2)}x · max ${num((config.kelly_max_fraction || 0) * 100, 1)}%`],
|
||
['Прогноз временных рядов', yesNo(config.time_series_forecast_enabled)],
|
||
['Модельный горизонт', `${config.time_series_forecast_horizon} свечи`],
|
||
['Walk-forward окно', `${config.time_series_validation_window} свечей`],
|
||
['Мин. edge прогноза', `${num(config.time_series_min_edge_percent, 3)}%`],
|
||
['Нейропрогноз', modelArtifactSummary(config)],
|
||
['Файл модели', config.time_series_lstm_model_path || '-'],
|
||
['Лимит в позициях', money(config.max_total_exposure_usdt)],
|
||
['Лимит позиций', `${config.max_open_positions} всего / ${config.max_positions_per_symbol} на пару`],
|
||
['Стоп / цель', `${num(config.stop_loss_percent * 100, 2)}% / ${num(config.take_profit_percent * 100, 2)}%`],
|
||
['Трейлинг-стоп', `${num(config.trailing_stop_percent * 100, 2)}%`],
|
||
['Удержание / пауза', `${config.min_hold_seconds}с / ${config.entry_cooldown_seconds}с`],
|
||
['Комиссия / проскальзывание', `${num(config.taker_fee_rate * 100, 3)}% / ${num(config.slippage_rate * 100, 3)}%`],
|
||
['Таймфрейм', `${config.base_interval}м`],
|
||
['Поток данных', yesNo(config.websocket_enabled)]
|
||
];
|
||
document.getElementById('configGrid').innerHTML = keys.map(([k, v]) => `
|
||
<div class="config-item"><span class="muted">${escapeHtml(k)}</span><strong>${escapeHtml(String(v))}</strong></div>
|
||
`).join('');
|
||
}
|
||
function renderFastMode(config) {
|
||
const fastBadge = document.getElementById('fastBadge');
|
||
fastBadge.textContent = config.fast_trading_enabled
|
||
? `Быстрый режим · ${num(config.effective_loop_interval_seconds, 2)}с`
|
||
: 'Обычный режим';
|
||
fastBadge.className = `badge ${config.fast_trading_enabled ? 'warn' : 'ok'}`;
|
||
const fastToggle = document.getElementById('fastToggle');
|
||
const fastToggleLabel = document.getElementById('fastToggleLabel');
|
||
fastToggle.checked = Boolean(config.fast_trading_enabled);
|
||
fastToggle.disabled = false;
|
||
fastToggleLabel.textContent = config.fast_trading_enabled
|
||
? 'Быстрая торговля: вкл'
|
||
: 'Быстрая торговля: выкл';
|
||
}
|
||
function renderLearning(learning, signals, config) {
|
||
const root = document.getElementById('learningPanel');
|
||
const patternStats = learning.pattern_stats || {};
|
||
const effects = strategyEffects(signals);
|
||
const learningEffects = effects.filter(item => Number.isFinite(item.learningAdjustment));
|
||
const activeLearning = learningEffects.filter(item => item.learningAdjustment !== 0);
|
||
const patternEffects = effects.filter(item => Number.isFinite(item.patternAdjustment) && item.patternAdjustment !== 0);
|
||
const avgLearning = average(activeLearning.map(item => item.learningAdjustment));
|
||
const lastLearning = activeLearning[0];
|
||
const ready = Boolean(learning.enabled) && Number(learning.sample_size || 0) >= Number(config.learning_min_samples || 0);
|
||
const losing = Number(learning.net_pnl || 0) < 0;
|
||
const stateClass = !learning.enabled ? 'warn' : ready ? (losing ? 'bad' : 'ok') : 'warn';
|
||
const stateText = !learning.enabled
|
||
? 'Выключено'
|
||
: ready
|
||
? (losing ? 'Работает, статистика минусовая' : 'Работает')
|
||
: 'Мало данных';
|
||
const stateHint = ready
|
||
? `${learning.sample_size || 0} закрытых сделок из окна ${config.learning_lookback_trades || '-'}`
|
||
: `${learning.sample_size || 0}/${config.learning_min_samples || 0} сделок до первых выводов`;
|
||
const rows = Object.entries(patternStats)
|
||
.sort((a, b) => (b[1].sample_size || 0) - (a[1].sample_size || 0))
|
||
.slice(0, 5);
|
||
const rules = learning.adaptive_rules || {};
|
||
const validation = rules.validation || {};
|
||
const ruleRows = [
|
||
['Разрешение торговли', rules.trade_permission || 'normal'],
|
||
['Режим риска', rules.risk_mode || 'neutral'],
|
||
['Экспозиция / цель', `${money(rules.current_total_exposure_usdt || 0)} / ${money(rules.target_total_exposure_usdt || config.max_total_exposure_usdt || 0)}`],
|
||
['Порог входа', signedNum(Number(rules.entry_threshold_adjustment || 0), 4)],
|
||
['Мин. удержание', `${rules.min_hold_seconds || config.min_hold_seconds || '-'} сек`],
|
||
['EMA-выход', exitRuleName(rules.ema_exit_mode)],
|
||
['RSI-выход', exitRuleName(rules.rsi_exit_mode)],
|
||
['Мин. прибыль выхода', `${num(rules.min_exit_profit_percent || 0, 3)}%`],
|
||
['Стоп / цель обучения', `${num((rules.stop_loss_percent || config.stop_loss_percent || 0) * 100, 2)}% / ${num((rules.take_profit_percent || config.take_profit_percent || 0) * 100, 2)}%`],
|
||
['Проверка правил', `${validation.status || '-'} / ${num(validation.avoided_loss_usdt || 0, 4)} USDT`],
|
||
];
|
||
const base = [
|
||
['Закрытых сделок', `${learning.sample_size || 0} / ${config.learning_lookback_trades || '-'}`],
|
||
['Итог обучения', `${num(learning.net_pnl, 4)} USDT`],
|
||
['Win rate', `${num((learning.win_rate || 0) * 100, 1)}%`],
|
||
['Активные поправки', `${activeLearning.length} из ${learningEffects.length}`],
|
||
['Средняя поправка', signedNum(avgLearning, 4)],
|
||
['Шаблоны влияют', `${patternEffects.length} сигналов`],
|
||
['Последняя поправка', lastLearning ? `${lastLearning.symbol} ${signedNum(lastLearning.learningAdjustment, 4)}` : 'нет'],
|
||
['Блокировки входа', `${effects.filter(item => item.blockedByPattern || item.blockedByLearning || item.blockedByAdaptive || item.blockedByForecast).length} сигналов`],
|
||
];
|
||
const stateHtml = `
|
||
<div class="learning-state-line">
|
||
<span class="state-pill ${stateClass}">${stateText}</span>
|
||
<span class="muted">${escapeHtml(stateHint)}</span>
|
||
</div>`;
|
||
const statHtml = `<div class="learning-metrics">${base.map(([k, v]) => `
|
||
<div class="learning-metric"><span>${k}</span><strong>${v}</strong></div>
|
||
`).join('')}</div>`;
|
||
const effectHtml = activeLearning.length
|
||
? `<div class="learning-section-title">Последние поправки обучения</div><div class="effect-list">${activeLearning.slice(0, 5).map(item => `
|
||
<div class="effect-row"><strong>${item.symbol}</strong><span class="small">${escapeHtml(item.pattern || item.reason || '-')}</span><strong class="${signedClass(item.learningAdjustment)}">${signedNum(item.learningAdjustment, 4)}</strong></div>
|
||
`).join('')}</div>`
|
||
: '<div class="muted">В последних сигналах активных поправок обучения нет.</div>';
|
||
const ruleHtml = `<div class="learning-section-title">Адаптивные правила</div>${ruleRows.map(([k, v]) => `
|
||
<div class="learning-row"><span>${escapeHtml(k)}</span><strong>${escapeHtml(String(v))}</strong></div>
|
||
`).join('')}`;
|
||
const patternHtml = rows.length
|
||
? `<div class="learning-section-title">Статистика по шаблонам</div>${rows.map(([name, stat]) => `
|
||
<div class="learning-row"><span>${escapeHtml(name)}</span><strong class="${signedClass(stat.net_pnl)}">${num(stat.net_pnl, 4)} / ${num((stat.win_rate || 0) * 100, 1)}%</strong></div>
|
||
`).join('')}`
|
||
: '<div class="muted">Закрытых сделок пока мало; статистика шаблонов не сформирована.</div>';
|
||
root.innerHTML = stateHtml + statHtml + ruleHtml + effectHtml + patternHtml;
|
||
}
|
||
function strategyEffects(signals) {
|
||
return signals.map(signal => {
|
||
const d = parseDiagnostics(signal);
|
||
const pattern = d.pattern || {};
|
||
const learning = d.learning || {};
|
||
return {
|
||
symbol: signal.symbol,
|
||
pattern: pattern.label || '',
|
||
reason: learning.reason || '',
|
||
baseScore: numberOrNull(d.base_score),
|
||
patternAdjustment: numberOrNull(d.pattern_adjustment),
|
||
learningAdjustment: numberOrNull(d.learning_adjustment),
|
||
forecastAdjustment: numberOrNull(d.forecast_adjustment),
|
||
finalScore: numberOrNull(d.final_score),
|
||
blockedByPattern: Boolean(d.entry_blocked_by_pattern),
|
||
blockedByLearning: Boolean(d.entry_blocked_by_learning),
|
||
blockedByAdaptive: Boolean(d.entry_blocked_by_adaptive_rules),
|
||
blockedByForecast: Boolean(d.entry_blocked_by_forecast),
|
||
};
|
||
}).filter(item => (
|
||
item.baseScore !== null
|
||
|| item.patternAdjustment !== null
|
||
|| item.learningAdjustment !== null
|
||
|| item.forecastAdjustment !== null
|
||
|| item.finalScore !== null
|
||
));
|
||
}
|
||
function parseDiagnostics(signal) {
|
||
try {
|
||
return JSON.parse(signal.diagnostics_json || '{}');
|
||
} catch (_) {
|
||
return {};
|
||
}
|
||
}
|
||
function exitRuleName(value) {
|
||
return value === 'profit_only' ? 'только при прибыли' : 'обычный';
|
||
}
|
||
function numberOrNull(value) {
|
||
const parsed = Number(value);
|
||
return Number.isFinite(parsed) ? parsed : null;
|
||
}
|
||
function average(values) {
|
||
const filtered = values.filter(value => Number.isFinite(value));
|
||
return filtered.length ? filtered.reduce((sum, value) => sum + value, 0) / filtered.length : 0;
|
||
}
|
||
function signedNum(value, digits = 4) {
|
||
const parsed = Number(value);
|
||
if (!Number.isFinite(parsed)) return '-';
|
||
const sign = parsed > 0 ? '+' : '';
|
||
return `${sign}${parsed.toFixed(digits)}`;
|
||
}
|
||
function escapeHtml(value) {
|
||
return String(value).replace(/[&<>"']/g, ch => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[ch]));
|
||
}
|
||
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) => {
|
||
const enabled = event.target.checked;
|
||
event.target.disabled = true;
|
||
setText('fastToggleLabel', enabled ? 'Включение...' : 'Выключение...');
|
||
try {
|
||
await fetchJson('/api/config/fast-trading', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ enabled })
|
||
});
|
||
await refresh();
|
||
} catch (err) {
|
||
event.target.checked = !enabled;
|
||
event.target.disabled = false;
|
||
setText('fastToggleLabel', enabled ? 'Быстрая торговля: выкл' : 'Быстрая торговля: вкл');
|
||
setText('subline', `Ошибка переключения быстрой торговли: ${err.message}`);
|
||
}
|
||
});
|
||
refresh().catch(err => setText('subline', `Ошибка загрузки: ${err.message}`));
|
||
setInterval(() => refresh().catch(err => setText('subline', `Ошибка обновления: ${err.message}`)), 3000);
|
||
</script>
|
||
</body>
|
||
</html>
|
||
"""
|