546 lines
30 KiB
JavaScript
546 lines
30 KiB
JavaScript
"use strict";
|
||
|
||
const state = {
|
||
snapshot: null,
|
||
authorization: "",
|
||
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);
|
||
showAuthDialog();
|
||
});
|
||
|
||
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();
|
||
const username = $("#usernameInput").value.trim();
|
||
const password = $("#passwordInput").value;
|
||
setText("authError", "");
|
||
if (!username || !password) return;
|
||
state.authorization = basicAuthorization(username, password);
|
||
await loadSnapshot(true, true);
|
||
});
|
||
}
|
||
|
||
function basicAuthorization(username, password) {
|
||
const bytes = new TextEncoder().encode(`${username}:${password}`);
|
||
let binary = "";
|
||
bytes.forEach((byte) => { binary += String.fromCharCode(byte); });
|
||
return `Basic ${btoa(binary)}`;
|
||
}
|
||
|
||
async function api(path, options = {}) {
|
||
const headers = { Accept: "application/json", ...(options.headers || {}) };
|
||
if (options.body) headers["Content-Type"] = "application/json";
|
||
if (state.authorization) headers.Authorization = state.authorization;
|
||
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("/web-api/dashboard/snapshot");
|
||
state.snapshot = snapshot;
|
||
render(snapshot);
|
||
setOffline(false);
|
||
if ($("#authDialog").open) $("#authDialog").close();
|
||
$("#passwordInput").value = "";
|
||
setText("authError", "");
|
||
scheduleRefresh(10000);
|
||
} catch (error) {
|
||
if (error instanceof AuthRequiredError) {
|
||
if (fromAuth) setText("authError", "Неверный логин или пароль.");
|
||
state.authorization = "";
|
||
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(() => $("#usernameInput").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(`/web-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("/web-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);
|
||
}
|