Files

570 lines
30 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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);
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;
const submitButton = $("#authSubmitButton");
setText("authError", "");
if (!username || !password) return;
if (username !== "sevenhill") {
setText("authError", "Неверный логин или пароль.");
return;
}
state.token = password.trim();
submitButton.disabled = true;
submitButton.setAttribute("aria-busy", "true");
setText("authSubmitButton", "Входим…");
setText("authError", "Проверяем доступ…");
try {
await loadSnapshot(true, true);
} finally {
submitButton.disabled = false;
submitButton.removeAttribute("aria-busy");
setText("authSubmitButton", "Войти");
}
});
}
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 controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 30000);
let response;
try {
response = await fetch(path, {
...options,
headers,
signal: controller.signal,
credentials: "omit",
cache: "no-store",
});
} catch (error) {
if (error?.name === "AbortError") throw new Error("Сервер не ответил за 30 секунд.");
throw error;
} finally {
clearTimeout(timeout);
}
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 false;
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);
return true;
} catch (error) {
if (error instanceof AuthRequiredError) {
if (fromAuth) setText("authError", "Неверный логин или пароль.");
state.token = "";
showAuthDialog();
} else if (fromAuth) {
setText("authError", `Ошибка подключения: ${error.message}`);
showAuthDialog();
} else {
setOffline(true, error.message);
scheduleRefresh(12000);
}
return false;
} 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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", "'": "&#39;", '"': "&quot;" }[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);
}