feat: add TradeBot web control panel
This commit is contained in:
@@ -0,0 +1,534 @@
|
||||
"use strict";
|
||||
|
||||
const state = {
|
||||
snapshot: null,
|
||||
token: "",
|
||||
loading: false,
|
||||
timer: null,
|
||||
marketFilter: "",
|
||||
};
|
||||
|
||||
const $ = (selector) => document.querySelector(selector);
|
||||
const $$ = (selector) => Array.from(document.querySelectorAll(selector));
|
||||
|
||||
class AuthRequiredError extends Error {}
|
||||
|
||||
const reasonLabels = {
|
||||
bot_not_running: "Торговый цикл остановлен",
|
||||
decision_loop_stale: "Цикл принятия решений не обновлялся вовремя",
|
||||
stale_market_data: "Есть устаревшие рыночные данные",
|
||||
repeated_loop_errors: "Обнаружены повторяющиеся ошибки цикла",
|
||||
forecast_model_not_ready: "Прогнозная модель не готова",
|
||||
live_reconciliation_blocking: "Сверка live-счёта блокирует новые действия",
|
||||
};
|
||||
|
||||
const actionLabels = {
|
||||
BUY: "Покупка",
|
||||
SELL: "Продажа",
|
||||
HOLD: "Ожидание",
|
||||
};
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
bindNavigation();
|
||||
bindControls();
|
||||
selectPage(location.hash.slice(1) || "overview", false);
|
||||
loadSnapshot();
|
||||
});
|
||||
|
||||
function bindNavigation() {
|
||||
$$("[data-page]").forEach((button) => {
|
||||
button.addEventListener("click", () => selectPage(button.dataset.page));
|
||||
});
|
||||
$$("[data-go]").forEach((button) => {
|
||||
button.addEventListener("click", () => selectPage(button.dataset.go));
|
||||
});
|
||||
window.addEventListener("hashchange", () => selectPage(location.hash.slice(1) || "overview", false));
|
||||
}
|
||||
|
||||
function selectPage(pageName, updateHash = true) {
|
||||
const valid = ["overview", "markets", "positions", "activity", "system"];
|
||||
const page = valid.includes(pageName) ? pageName : "overview";
|
||||
$$(".page").forEach((item) => item.classList.toggle("is-active", item.id === `page-${page}`));
|
||||
$$("[data-page]").forEach((item) => {
|
||||
const active = item.dataset.page === page;
|
||||
item.classList.toggle("is-active", active);
|
||||
if (active) item.setAttribute("aria-current", "page");
|
||||
else item.removeAttribute("aria-current");
|
||||
});
|
||||
const activePage = $(`#page-${page}`);
|
||||
setText("pageTitle", activePage?.dataset.title || "Обзор");
|
||||
if (updateHash && location.hash !== `#${page}`) history.pushState(null, "", `#${page}`);
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
|
||||
function bindControls() {
|
||||
$("#refreshButton").addEventListener("click", () => loadSnapshot(true));
|
||||
["#startButton", "#systemStartButton"].forEach((selector) => {
|
||||
$(selector).addEventListener("click", () => requestControl("start"));
|
||||
});
|
||||
["#stopButton", "#systemStopButton"].forEach((selector) => {
|
||||
$(selector).addEventListener("click", () => requestControl("stop"));
|
||||
});
|
||||
$("#fastTradingToggle").addEventListener("change", onFastTradingChange);
|
||||
$("#marketSearch").addEventListener("input", (event) => {
|
||||
state.marketFilter = event.target.value.trim().toUpperCase();
|
||||
renderMarkets(state.snapshot?.markets?.markets || []);
|
||||
});
|
||||
$("#authForm").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
state.token = $("#tokenInput").value.trim();
|
||||
setText("authError", "");
|
||||
if (!state.token) return;
|
||||
await loadSnapshot(true, true);
|
||||
});
|
||||
}
|
||||
|
||||
async function api(path, options = {}) {
|
||||
const headers = { Accept: "application/json", ...(options.headers || {}) };
|
||||
if (options.body) headers["Content-Type"] = "application/json";
|
||||
if (state.token) headers["X-TradeBot-Token"] = state.token;
|
||||
const response = await fetch(path, {
|
||||
...options,
|
||||
headers,
|
||||
credentials: "same-origin",
|
||||
cache: "no-store",
|
||||
});
|
||||
if (response.status === 401) throw new AuthRequiredError("Требуется авторизация");
|
||||
let payload = null;
|
||||
try { payload = await response.json(); } catch (_) { payload = null; }
|
||||
if (!response.ok) {
|
||||
const detail = payload?.detail?.message || payload?.detail || payload?.error || `HTTP ${response.status}`;
|
||||
throw new Error(typeof detail === "string" ? detail : JSON.stringify(detail));
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function loadSnapshot(manual = false, fromAuth = false) {
|
||||
if (state.loading) return;
|
||||
state.loading = true;
|
||||
clearTimeout(state.timer);
|
||||
$("#refreshButton").classList.add("is-spinning");
|
||||
if (manual) setText("syncLabel", "Обновление…");
|
||||
try {
|
||||
const snapshot = await api("/api/dashboard/snapshot");
|
||||
state.snapshot = snapshot;
|
||||
render(snapshot);
|
||||
setOffline(false);
|
||||
if ($("#authDialog").open) $("#authDialog").close();
|
||||
setText("authError", "");
|
||||
scheduleRefresh(10000);
|
||||
} catch (error) {
|
||||
if (error instanceof AuthRequiredError) {
|
||||
if (fromAuth) setText("authError", "Токен не принят сервером.");
|
||||
showAuthDialog();
|
||||
} else {
|
||||
setOffline(true, error.message);
|
||||
scheduleRefresh(12000);
|
||||
}
|
||||
} finally {
|
||||
state.loading = false;
|
||||
$("#refreshButton").classList.remove("is-spinning");
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleRefresh(delay) {
|
||||
clearTimeout(state.timer);
|
||||
state.timer = setTimeout(() => loadSnapshot(), delay);
|
||||
}
|
||||
|
||||
function showAuthDialog() {
|
||||
const dialog = $("#authDialog");
|
||||
if (!dialog.open) dialog.showModal();
|
||||
setText("syncLabel", "Нужна авторизация");
|
||||
setTimeout(() => $("#tokenInput").focus(), 50);
|
||||
}
|
||||
|
||||
function setOffline(offline, message = "") {
|
||||
$("#offlineBanner").hidden = !offline;
|
||||
setText("offlineMessage", message || "Повторное подключение выполняется автоматически.");
|
||||
setText("sideConnection", offline ? "Нет связи" : "Сервер доступен");
|
||||
$("#sideConnectionDot").classList.toggle("is-offline", offline);
|
||||
$("#sideConnectionDot").classList.toggle("is-online", !offline);
|
||||
if (offline) setText("syncLabel", "Соединение потеряно");
|
||||
}
|
||||
|
||||
function render(data) {
|
||||
const health = data.health || {};
|
||||
const envelope = data.status || {};
|
||||
const status = envelope.status || {};
|
||||
const readiness = envelope.readiness || {};
|
||||
const account = envelope.account || {};
|
||||
const positions = envelope.positions || [];
|
||||
const markets = data.markets || {};
|
||||
const closed = data.trades?.closed_summary || {};
|
||||
const config = data.config || {};
|
||||
|
||||
setText("appVersion", health.version || "—");
|
||||
setText("modeBadge", String(health.mode || "—").toUpperCase());
|
||||
setText("syncLabel", `Обновлено ${formatClock(data.generated_at)}`);
|
||||
setText("sideConnection", "Сервер доступен");
|
||||
$("#sideConnectionDot").classList.add("is-online");
|
||||
|
||||
renderHero(status, readiness, markets);
|
||||
renderMetrics(account, positions, closed);
|
||||
renderReadiness(status, readiness, markets);
|
||||
renderModel(config, readiness, data.retrain || {}, markets);
|
||||
renderOverviewMarkets(markets.markets || []);
|
||||
renderMarkets(markets.markets || []);
|
||||
renderPositions(positions, config);
|
||||
renderActivity(data);
|
||||
renderSystem(data);
|
||||
}
|
||||
|
||||
function renderHero(status, readiness, markets) {
|
||||
const running = Boolean(status.running);
|
||||
const ready = Boolean(readiness.ready);
|
||||
const orb = $("#heroOrb");
|
||||
orb.classList.toggle("is-good", running && ready);
|
||||
orb.classList.toggle("is-bad", !running);
|
||||
if (!running) {
|
||||
setText("heroKicker", "Цикл остановлен");
|
||||
setText("heroTitle", "Бот сейчас не торгует");
|
||||
setText("heroText", "Данные и позиции сохранены. Запуск возобновит анализ рынка и обработку торговых решений.");
|
||||
} else if (ready) {
|
||||
setText("heroKicker", "Контур готов");
|
||||
setText("heroTitle", "Бот работает штатно");
|
||||
setText("heroText", "Торговый цикл активен, рыночные данные свежие, обязательные проверки пройдены.");
|
||||
} else {
|
||||
setText("heroKicker", "Работа с ограничениями");
|
||||
setText("heroTitle", "Бот активен, но есть предупреждения");
|
||||
setText("heroText", (readiness.reasons || []).map(reasonLabel).join(" · ") || "Сервер сообщил об ограниченной готовности.");
|
||||
}
|
||||
setText("lastLoop", status.last_loop_at ? timeAgo(status.last_loop_at) : "нет данных");
|
||||
setText("wsState", markets.ws_connected ? "подключён" : "нет связи");
|
||||
setText("symbolCount", String((markets.symbols || []).length));
|
||||
toggleControlButtons(running);
|
||||
}
|
||||
|
||||
function toggleControlButtons(running) {
|
||||
["#startButton", "#systemStartButton"].forEach((selector) => { $(selector).disabled = running; });
|
||||
["#stopButton", "#systemStopButton"].forEach((selector) => { $(selector).disabled = !running; });
|
||||
const badge = $("#controlBadge");
|
||||
badge.textContent = running ? "Работает" : "Остановлен";
|
||||
badge.className = `badge ${running ? "is-good" : "is-bad"}`;
|
||||
}
|
||||
|
||||
function renderMetrics(account, positions, closed) {
|
||||
const equity = number(account.equity);
|
||||
const cash = number(account.cash);
|
||||
const net = number(account.net_pnl);
|
||||
const exposure = positions.reduce((sum, row) => sum + number(row.market_value), 0);
|
||||
setText("metricEquity", money(equity));
|
||||
setText("metricCash", money(cash));
|
||||
setText("metricPositions", String(positions.length));
|
||||
setText("metricExposure", `Экспозиция ${money(exposure)}`);
|
||||
setText("metricTrades", String(closed.trades ?? 0));
|
||||
setText("metricWinRate", `Win rate ${percent(number(closed.win_rate) * 100, 1)}`);
|
||||
const delta = $("#metricEquityDelta");
|
||||
delta.textContent = `${signedMoney(net)} · ${signedPercent(number(account.net_pnl_percent), 2)}`;
|
||||
applyTone(delta, net);
|
||||
}
|
||||
|
||||
function renderReadiness(status, readiness, markets) {
|
||||
const ready = Boolean(readiness.ready);
|
||||
const badge = $("#readyBadge");
|
||||
badge.textContent = ready ? "Готов" : status.running ? "Ограничен" : "Стоп";
|
||||
badge.className = `badge ${ready ? "is-good" : status.running ? "is-warn" : "is-bad"}`;
|
||||
setText("readyScore", ready ? "READY" : "CHECK");
|
||||
const checks = [
|
||||
{ ok: Boolean(status.running), label: status.running ? "Торговый цикл запущен" : "Торговый цикл остановлен" },
|
||||
{ ok: !(readiness.reasons || []).includes("decision_loop_stale"), label: "Цикл решений обновляется вовремя" },
|
||||
{ ok: Boolean(markets.ws_connected), label: markets.ws_connected ? "Bybit WebSocket подключён" : "Bybit WebSocket не подключён" },
|
||||
{ ok: !(readiness.stale_symbols || []).length, label: (readiness.stale_symbols || []).length ? `Устарели: ${readiness.stale_symbols.join(", ")}` : "Рыночные данные свежие" },
|
||||
{
|
||||
ok: Boolean(readiness.forecast_model_ready),
|
||||
warn: Boolean(readiness.forecast_fallback_active),
|
||||
label: readiness.forecast_model_ready ? "Прогнозная модель готова" : readiness.forecast_fallback_active ? "Активен резервный режим прогноза" : "Модель прогноза не готова",
|
||||
},
|
||||
];
|
||||
$("#readinessList").innerHTML = checks.map((item) => `<li><span class="check-dot ${item.ok ? "" : item.warn ? "is-warn" : "is-bad"}"></span>${escapeHtml(item.label)}</li>`).join("");
|
||||
}
|
||||
|
||||
function renderModel(config, readiness, retrain, markets) {
|
||||
const artifact = config.time_series_model_artifact || {};
|
||||
const shadow = retrain.shadow || {};
|
||||
const collector = markets.observation_collector || {};
|
||||
setText("modelTitle", artifact.available ? artifact.label || artifact.type || "Модель загружена" : "Артефакт недоступен");
|
||||
setText("modelSubtitle", artifact.available ? `${artifact.symbol_count ?? 0} пар · создана ${formatDateTime(artifact.created_at)}` : "Сервер не подтвердил наличие модели");
|
||||
setText("shadowGate", shadowStateLabel(shadow));
|
||||
setText("fallbackState", readiness.forecast_fallback_active ? "активен" : "не активен");
|
||||
setText("collectorState", collector.enabled ? `${collector.samples_since_start ?? 0} с запуска` : "выключен");
|
||||
}
|
||||
|
||||
function renderOverviewMarkets(markets) {
|
||||
const rows = markets.filter((market) => market.ticker).slice(0, 6);
|
||||
$("#overviewMarkets").innerHTML = rows.length ? rows.map((market) => {
|
||||
const ticker = market.ticker || {};
|
||||
const forecast = market.forecast || {};
|
||||
const quality = market.quality || {};
|
||||
const change = number(ticker.change_24h);
|
||||
const edge = forecastValue(forecast);
|
||||
const forecastUsable = isForecastUsable(forecast);
|
||||
return `<tr>
|
||||
<td class="symbol-cell">${escapeHtml(ticker.symbol || "—")}<small>${escapeHtml(modelName(forecast))}</small></td>
|
||||
<td>${formatPrice(ticker.last_price)}</td>
|
||||
<td class="${toneClass(change)}">${signedPercent(change, 2)}</td>
|
||||
<td class="${toneClass(edge)}">${forecastUsable ? signedPercent(edge, 2) : "—"}</td>
|
||||
<td>${sparkline(market.sparkline || [], change)}</td>
|
||||
<td>${qualityLabel(quality)}</td>
|
||||
</tr>`;
|
||||
}).join("") : `<tr><td colspan="6" class="empty">Рынок пока не вернул котировки.</td></tr>`;
|
||||
}
|
||||
|
||||
function renderMarkets(markets) {
|
||||
const filter = state.marketFilter;
|
||||
const rows = markets.filter((market) => {
|
||||
const symbol = market.ticker?.symbol || "";
|
||||
return market.ticker && (!filter || symbol.includes(filter));
|
||||
});
|
||||
$("#marketsTable").innerHTML = rows.length ? rows.map((market) => {
|
||||
const ticker = market.ticker || {};
|
||||
const forecast = market.forecast || {};
|
||||
const quality = market.quality || {};
|
||||
const change = number(ticker.change_24h);
|
||||
const edge = forecastValue(forecast);
|
||||
const probability = probabilityValue(forecast);
|
||||
const forecastUsable = isForecastUsable(forecast);
|
||||
return `<tr>
|
||||
<td class="symbol-cell">${escapeHtml(ticker.symbol || "—")}<small>${escapeHtml(modelName(forecast))}</small></td>
|
||||
<td>${formatPrice(ticker.last_price)}</td>
|
||||
<td>${formatPrice(ticker.bid)} / ${formatPrice(ticker.ask)}</td>
|
||||
<td class="${toneClass(change)}">${signedPercent(change, 2)}</td>
|
||||
<td class="${toneClass(edge)}">${forecastUsable ? signedPercent(edge, 2) : "—"}</td>
|
||||
<td>${!forecastUsable || probability == null ? "—" : percent(probability * 100, 1)}</td>
|
||||
<td>${percent(number(ticker.spread_percent), 3)}</td>
|
||||
<td>${qualityLabel(quality)}</td>
|
||||
</tr>`;
|
||||
}).join("") : `<tr><td colspan="8" class="empty">${filter ? "Совпадений не найдено." : "Рынок пока не вернул котировки."}</td></tr>`;
|
||||
}
|
||||
|
||||
function renderPositions(positions, config) {
|
||||
const totalValue = positions.reduce((sum, row) => sum + number(row.market_value), 0);
|
||||
const totalPnl = positions.reduce((sum, row) => sum + number(row.unrealized_pnl), 0);
|
||||
const totalNotional = positions.reduce((sum, row) => sum + number(row.notional_usdt), 0);
|
||||
const totalPnlPercent = totalNotional ? totalPnl / totalNotional * 100 : 0;
|
||||
setText("positionCount", String(positions.length));
|
||||
setText("positionValue", money(totalValue));
|
||||
setText("positionPnl", signedMoney(totalPnl));
|
||||
setText("positionPnlPercent", signedPercent(totalPnlPercent, 2));
|
||||
setText("exposureLimit", money(config.max_total_exposure_usdt));
|
||||
applyTone($("#positionPnl"), totalPnl);
|
||||
applyTone($("#positionPnlPercent"), totalPnl);
|
||||
|
||||
$("#overviewPositions").innerHTML = positions.length ? positions.slice(0, 6).map((position) => `<div class="position-row">
|
||||
<div><span>Пара</span><strong>${escapeHtml(position.symbol || "—")}</strong></div>
|
||||
<div><span>Стоимость</span><strong>${money(position.market_value)}</strong></div>
|
||||
<div><span>Цена сейчас</span><strong>${formatPrice(position.mark_price)}</strong></div>
|
||||
<div><span>PnL</span><strong class="${toneClass(number(position.unrealized_pnl))}">${signedMoney(position.unrealized_pnl)}</strong></div>
|
||||
<div><span>План</span><strong>${escapeHtml(actionLabels[position.exit_plan?.action] || position.exit_plan?.action || "Ожидание")}</strong></div>
|
||||
</div>`).join("") : `<div class="empty-block">Открытых позиций нет.</div>`;
|
||||
|
||||
$("#positionsTable").innerHTML = positions.length ? positions.map((position) => `<tr>
|
||||
<td class="symbol-cell">${escapeHtml(position.symbol || "—")}<small>${escapeHtml(position.mode || "")}</small></td>
|
||||
<td>${formatQuantity(position.qty)}</td>
|
||||
<td>${formatPrice(position.entry_price)}</td>
|
||||
<td>${formatPrice(position.mark_price)}</td>
|
||||
<td>${money(position.market_value)}</td>
|
||||
<td class="${toneClass(number(position.unrealized_pnl))}">${signedMoney(position.unrealized_pnl)}<br><small>${signedPercent(number(position.unrealized_pnl_percent), 2)}</small></td>
|
||||
<td>${escapeHtml(actionLabels[position.exit_plan?.action] || position.exit_plan?.action || "Ожидание")}</td>
|
||||
<td>${formatDateTime(position.opened_at)}</td>
|
||||
</tr>`).join("") : `<tr><td colspan="8" class="empty">Открытых позиций нет.</td></tr>`;
|
||||
}
|
||||
|
||||
function renderActivity(data) {
|
||||
const signals = data.signals?.items || [];
|
||||
const trades = data.trades?.items || [];
|
||||
const events = data.events?.items || [];
|
||||
$("#signalFeed").innerHTML = signals.length ? signals.map((item) => {
|
||||
const action = String(item.action || "HOLD").toUpperCase();
|
||||
const tone = action === "BUY" ? "positive" : action === "SELL" ? "negative" : "warning";
|
||||
return `<article class="feed-item"><div class="feed-top"><strong>${escapeHtml(item.symbol || "—")}</strong><time>${formatDateTime(item.created_at)}</time></div><p>${escapeHtml(item.reason || "Причина не указана")}</p><div class="feed-meta"><span class="action-label ${tone}">${escapeHtml(actionLabels[action] || action)}</span><span>confidence ${percent(number(item.confidence) * 100, 1)}</span></div></article>`;
|
||||
}).join("") : `<div class="empty-block">Сигналов пока нет.</div>`;
|
||||
|
||||
$("#tradeFeed").innerHTML = trades.length ? trades.map((item) => {
|
||||
const side = String(item.side || "").toUpperCase();
|
||||
const pnl = number(item.net_pnl);
|
||||
return `<article class="feed-item"><div class="feed-top"><strong>${escapeHtml(item.symbol || "—")} · <span class="${side === "SELL" ? "negative" : "positive"}">${escapeHtml(side)}</span></strong><time>${formatDateTime(item.closed_at || item.opened_at)}</time></div><p>${escapeHtml(item.reason || (side === "BUY" ? "Позиция открыта" : "Сделка исполнена"))}</p><div class="feed-meta"><span>${formatQuantity(item.qty)} ед.</span><span class="${toneClass(pnl)}">PnL ${signedMoney(pnl)}</span><span>fee ${money(item.fee_usdt)}</span></div></article>`;
|
||||
}).join("") : `<div class="empty-block">Сделок пока нет.</div>`;
|
||||
|
||||
$("#eventFeed").innerHTML = events.length ? events.map((item) => {
|
||||
const level = String(item.level || "INFO").toUpperCase();
|
||||
const tone = level === "ERROR" ? "negative" : level === "WARN" ? "warning" : "";
|
||||
return `<article class="feed-item"><div class="feed-top"><strong class="${tone}">${escapeHtml(level)}</strong><time>${formatDateTime(item.created_at)}</time></div><p>${escapeHtml(item.message || "—")}</p></article>`;
|
||||
}).join("") : `<div class="empty-block">Событий пока нет.</div>`;
|
||||
}
|
||||
|
||||
function renderSystem(data) {
|
||||
const config = data.config || {};
|
||||
const markets = data.markets || {};
|
||||
const retrain = data.retrain || {};
|
||||
const coordination = retrain.coordination || {};
|
||||
const shadow = retrain.shadow || {};
|
||||
const activeJob = coordination.active_job || coordination.latest_job;
|
||||
$("#fastTradingToggle").checked = Boolean(config.fast_trading_enabled);
|
||||
setText("fastTradingHint", `Интервал ${formatDuration(config.effective_loop_interval_seconds)} · cooldown ${formatDuration(config.effective_entry_cooldown_seconds)}`);
|
||||
|
||||
$("#trainingDetails").innerHTML = definitionRows([
|
||||
["Модель", config.time_series_model_artifact?.label || "нет данных"],
|
||||
["Windows-агент", coordination.agent_online ? coordination.agent_busy ? "занят" : "онлайн" : "не в сети"],
|
||||
["Последняя задача", activeJob?.status || "нет задач"],
|
||||
["Shadow gate", shadowStateLabel(shadow)],
|
||||
["Forward predictions", `${shadow.settled_predictions ?? 0} settled / ${shadow.eligible_predictions ?? 0} eligible`],
|
||||
]);
|
||||
|
||||
$("#networkDetails").innerHTML = definitionRows([
|
||||
["WebSocket", markets.ws_connected ? "подключён" : "нет связи"],
|
||||
["Последнее WS-сообщение", formatDateTime(markets.last_ws_message_at)],
|
||||
["Последний REST refresh", formatDateTime(markets.last_rest_refresh_at)],
|
||||
["REST-ошибки", String(markets.rest_error_count ?? 0)],
|
||||
["Сбор L1", markets.observation_collector?.enabled ? `включён · ${markets.observation_collector.samples_since_start ?? 0}` : "выключен"],
|
||||
]);
|
||||
|
||||
$("#configDetails").innerHTML = definitionRows([
|
||||
["Стратегия", config.strategy_mode || "—"],
|
||||
["Базовый интервал", config.base_interval ? `${config.base_interval} мин` : "—"],
|
||||
["Profit-only выход", config.profit_only_exit_enabled ? `включён · min ${percent(config.min_exit_net_percent, 2)}` : "выключен"],
|
||||
["Risk guard", config.risk_guard_enabled ? "включён" : "выключен"],
|
||||
["Общая экспозиция", `${money(config.max_total_exposure_usdt)} USDT`],
|
||||
["Макс. позиций", String(config.max_open_positions ?? "—")],
|
||||
]);
|
||||
}
|
||||
|
||||
async function requestControl(action) {
|
||||
const start = action === "start";
|
||||
const confirmed = await askConfirm(
|
||||
start ? "Запустить торговый цикл?" : "Остановить торговый цикл?",
|
||||
start
|
||||
? "Бот возобновит анализ рынка и обработку решений. Текущий режим торговли не изменится."
|
||||
: "Новые решения перестанут обрабатываться. Открытые позиции и история останутся сохранены.",
|
||||
start ? "Запустить" : "Остановить",
|
||||
);
|
||||
if (!confirmed) return;
|
||||
setControlsBusy(true);
|
||||
try {
|
||||
await api(`/api/control/${action}`, { method: "POST" });
|
||||
toast(start ? "Торговый цикл запущен." : "Торговый цикл остановлен.");
|
||||
await loadSnapshot(true);
|
||||
} catch (error) {
|
||||
if (error instanceof AuthRequiredError) showAuthDialog();
|
||||
else toast(error.message, true);
|
||||
} finally {
|
||||
toggleControlButtons(Boolean(state.snapshot?.status?.status?.running));
|
||||
}
|
||||
}
|
||||
|
||||
async function onFastTradingChange(event) {
|
||||
const toggle = event.target;
|
||||
const previous = !toggle.checked;
|
||||
const enabled = toggle.checked;
|
||||
const confirmed = await askConfirm(
|
||||
enabled ? "Включить быструю торговлю?" : "Выключить быструю торговлю?",
|
||||
enabled
|
||||
? "Сервер уменьшит интервал цикла и cooldown входа согласно текущей конфигурации."
|
||||
: "Сервер вернётся к обычному интервалу принятия решений.",
|
||||
enabled ? "Включить" : "Выключить",
|
||||
);
|
||||
if (!confirmed) { toggle.checked = previous; return; }
|
||||
toggle.disabled = true;
|
||||
try {
|
||||
const result = await api("/api/config/fast-trading", { method: "POST", body: JSON.stringify({ enabled }) });
|
||||
toast(`Быстрая торговля ${enabled ? "включена" : "выключена"}${result.env_persisted === false ? " только в runtime" : ""}.`);
|
||||
await loadSnapshot(true);
|
||||
} catch (error) {
|
||||
toggle.checked = previous;
|
||||
if (error instanceof AuthRequiredError) showAuthDialog();
|
||||
else toast(error.message, true);
|
||||
} finally {
|
||||
toggle.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function askConfirm(title, text, actionLabel) {
|
||||
const dialog = $("#confirmDialog");
|
||||
setText("confirmTitle", title);
|
||||
setText("confirmText", text);
|
||||
setText("confirmAction", actionLabel);
|
||||
dialog.showModal();
|
||||
return new Promise((resolve) => {
|
||||
dialog.addEventListener("close", () => resolve(dialog.returnValue === "confirm"), { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
function setControlsBusy(busy) {
|
||||
["#startButton", "#stopButton", "#systemStartButton", "#systemStopButton"].forEach((selector) => { $(selector).disabled = busy; });
|
||||
}
|
||||
|
||||
function definitionRows(rows) {
|
||||
return rows.map(([term, value]) => `<div><dt>${escapeHtml(term)}</dt><dd>${escapeHtml(value ?? "—")}</dd></div>`).join("");
|
||||
}
|
||||
|
||||
function sparkline(points, change) {
|
||||
const values = points.map((point) => number(point.close)).filter((value) => Number.isFinite(value) && value > 0);
|
||||
if (values.length < 2) return "—";
|
||||
const min = Math.min(...values);
|
||||
const max = Math.max(...values);
|
||||
const range = max - min || 1;
|
||||
const path = values.map((value, index) => `${(index / (values.length - 1) * 88).toFixed(1)},${(26 - ((value - min) / range) * 22).toFixed(1)}`).join(" ");
|
||||
return `<svg class="sparkline ${change < 0 ? "is-down" : ""}" viewBox="0 0 88 28" aria-hidden="true"><polyline points="${path}"/></svg>`;
|
||||
}
|
||||
|
||||
function qualityLabel(quality) {
|
||||
const status = quality?.status || "unknown";
|
||||
const text = status === "ok" ? "Норма" : status === "warn" ? "Внимание" : status === "error" ? "Ошибка" : "Нет данных";
|
||||
const tone = status === "ok" ? "positive" : status === "warn" ? "warning" : status === "error" ? "negative" : "";
|
||||
return `<span class="row-state ${tone}">${text}</span>`;
|
||||
}
|
||||
|
||||
function modelName(forecast) {
|
||||
if (!isForecastUsable(forecast)) return "нет модели";
|
||||
return forecast?.model_label || forecast?.model || "прогноз";
|
||||
}
|
||||
|
||||
function isForecastUsable(forecast) {
|
||||
if (!forecast || forecast.usable === false) return false;
|
||||
return Boolean(forecast.usable || (forecast.model && forecast.model !== "none"));
|
||||
}
|
||||
|
||||
function forecastValue(forecast) {
|
||||
return number(forecast?.expected_return_percent ?? forecast?.edge_percent ?? forecast?.expected_percent);
|
||||
}
|
||||
|
||||
function probabilityValue(forecast) {
|
||||
const raw = forecast?.probability_up ?? forecast?.probability;
|
||||
if (raw === null || raw === undefined || raw === "") return null;
|
||||
const value = number(raw);
|
||||
return value > 1 ? value / 100 : value;
|
||||
}
|
||||
|
||||
function reasonLabel(reason) { return reasonLabels[reason] || String(reason || "Неизвестное ограничение"); }
|
||||
function shadowStateLabel(shadow) { if (shadow?.passed) return "пройден"; return ({ collecting: "сбор данных", failed: "не пройден", passed: "пройден" })[shadow?.state] || "нет данных"; }
|
||||
function number(value) { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : 0; }
|
||||
function setText(id, value) { const node = document.getElementById(id); if (node) node.textContent = String(value ?? "—"); }
|
||||
function money(value) { return number(value).toLocaleString("ru-RU", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); }
|
||||
function signedMoney(value) { const amount = number(value); return `${amount > 0 ? "+" : ""}${money(amount)} USDT`; }
|
||||
function percent(value, digits = 2) { return `${number(value).toLocaleString("ru-RU", { minimumFractionDigits: digits, maximumFractionDigits: digits })}%`; }
|
||||
function signedPercent(value, digits = 2) { const amount = number(value); return `${amount > 0 ? "+" : ""}${percent(amount, digits)}`; }
|
||||
function formatPrice(value) { const amount = number(value); if (!amount) return "—"; const digits = amount >= 1000 ? 2 : amount >= 1 ? 4 : 6; return amount.toLocaleString("ru-RU", { maximumFractionDigits: digits }); }
|
||||
function formatQuantity(value) { return number(value).toLocaleString("ru-RU", { maximumFractionDigits: 8 }); }
|
||||
function formatDuration(value) { const seconds = number(value); return seconds < 60 ? `${seconds.toLocaleString("ru-RU", { maximumFractionDigits: 1 })} с` : `${(seconds / 60).toLocaleString("ru-RU", { maximumFractionDigits: 1 })} мин`; }
|
||||
function formatClock(value) { const date = new Date(value); return Number.isNaN(date.getTime()) ? "—" : date.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit", second: "2-digit" }); }
|
||||
function formatDateTime(value) { const date = new Date(value); return !value || Number.isNaN(date.getTime()) ? "—" : date.toLocaleString("ru-RU", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" }); }
|
||||
function timeAgo(value) { const date = new Date(value); if (Number.isNaN(date.getTime())) return "—"; const seconds = Math.max(0, Math.round((Date.now() - date.getTime()) / 1000)); if (seconds < 5) return "сейчас"; if (seconds < 60) return `${seconds} с назад`; const minutes = Math.round(seconds / 60); if (minutes < 60) return `${minutes} мин назад`; return formatDateTime(value); }
|
||||
function toneClass(value) { return number(value) > 0 ? "positive" : number(value) < 0 ? "negative" : ""; }
|
||||
function applyTone(node, value) { node.classList.remove("positive", "negative"); if (number(value) > 0) node.classList.add("positive"); if (number(value) < 0) node.classList.add("negative"); }
|
||||
function escapeHtml(value) { return String(value ?? "").replace(/[&<>'"]/g, (char) => ({ "&": "&", "<": "<", ">": ">", "'": "'", '"': """ }[char])); }
|
||||
|
||||
let toastTimer = null;
|
||||
function toast(message, error = false) {
|
||||
const node = $("#toast");
|
||||
node.textContent = message;
|
||||
node.classList.toggle("is-error", error);
|
||||
node.classList.add("is-visible");
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => node.classList.remove("is-visible"), 3500);
|
||||
}
|
||||
Reference in New Issue
Block a user