Files
gridbot/templates/dashboard.html.bak.grid_placed.20260607_034603

1459 lines
52 KiB
Plaintext
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.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Grid Bot</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: #0a0e17;
color: #e0e3eb;
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
min-height: 100vh;
padding: 24px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid #1f2937;
}
.header h1 { font-size: 22px; font-weight: 700; }
.badge {
padding: 4px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
}
.badge-demo { background: #1a3a2a; color: #4ade80; border: 1px solid #22c55e44; }
.badge-live { background: #3a1a1a; color: #f87171; border: 1px solid #ef444444; }
.grid-top {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.card {
background: #111827;
border: 1px solid #1f2937;
border-radius: 16px;
padding: 20px 24px;
}
.card-label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
color: #6b7280;
margin-bottom: 8px;
}
.card-value {
font-size: 28px;
font-weight: 700;
}
.card-sub { font-size: 12px; color: #6b7280; margin-top: 4px; }
.card-value.up { color: #4ade80; }
.card-value.down { color: #f87171; }
.card-value.neutral { color: #e0e3eb; }
.card-label {
display: flex;
justify-content: space-between;
align-items: center;
}
.balance-status {
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 2px 6px;
border-radius: 4px;
}
.balance-status.fresh { background: #1a3a2a; color: #4ade80; }
.balance-status.stale { background: #422006; color: #fbbf24; }
.balance-status.error { background: #3a1a1a; color: #f87171; }
.balance-extra {
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid #1f2937;
font-size: 12px;
color: #9ca3af;
max-height: 96px;
overflow-y: auto;
}
.balance-extra .row {
display: flex;
justify-content: space-between;
padding: 2px 0;
font-family: 'Courier New', monospace;
}
.balance-extra .row.primary { color: #e0e3eb; font-weight: 600; }
.balance-extra .row .cur { color: #6b7280; }
.balance-extra .row .total { color: #e0e3eb; }
.price-row {
display: flex;
align-items: baseline;
gap: 12px;
}
.price-change {
font-size: 14px;
font-weight: 600;
padding: 2px 8px;
border-radius: 6px;
}
.price-change.up { background: #14532d; color: #4ade80; }
.price-change.down { background: #7f1d1d; color: #f87171; }
.stale-warn {
margin-top: 8px;
padding: 6px 10px;
background: #422006;
color: #fbbf24;
border: 1px solid #f59e0b44;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
}
/* Grid visualization */
.grid-card { grid-column: 1 / -1; }
.grid-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.grid-header h2 { font-size: 16px; font-weight: 600; }
.grid-header span { font-size: 13px; color: #6b7280; }
.chart-card { grid-column: 1 / -1; }
.chart-wrap {
position: relative;
height: 320px;
margin-top: 8px;
}
.chart-wrap canvas { width: 100% !important; height: 100% !important; }
.range-buttons {
display: inline-flex;
gap: 4px;
background: #0a0e17;
padding: 3px;
border-radius: 8px;
border: 1px solid #1f2937;
}
.range-buttons button {
background: transparent;
border: none;
color: #9ca3af;
font-size: 12px;
font-weight: 600;
padding: 4px 10px;
border-radius: 6px;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.range-buttons button:hover { color: #e0e3eb; }
.range-buttons button.active {
background: #2563eb;
color: #fff;
}
.chart-toolbar {
display: flex;
gap: 12px;
flex-wrap: wrap;
align-items: center;
}
.chart-legend {
display: flex;
flex-wrap: wrap;
gap: 16px;
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid #1f2937;
font-size: 12px;
color: #9ca3af;
}
.chart-legend > span { display: inline-flex; align-items: center; gap: 6px; }
.legend-dot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
}
.grid-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
padding: 8px 12px;
border-radius: 10px;
font-size: 13px;
font-family: 'Courier New', monospace;
}
.grid-row.BUY { background: #0f2618; border-left: 3px solid #22c55e; }
.grid-row.SELL { background: #260f13; border-left: 3px solid #f87171; }
.grid-row th {
font-weight: 600;
color: #6b7280;
text-transform: uppercase;
font-size: 10px;
letter-spacing: 1px;
}
.grid-col-side { width: 50px; font-weight: 700; font-size: 11px; }
.grid-col-price { flex: 1; text-align: right; color: #e0e3eb; }
.grid-col-dist { width: 80px; text-align: right; color: #6b7280; font-size: 11px; }
.grid-col-qty { width: 80px; text-align: right; color: #6b7280; }
.grid-col-f { width: 60px; text-align: right; font-size: 11px; }
.grid-col-f.filled { color: #4ade80; }
.grid-col-f.pending { color: #6b7280; }
/* Settings */
.settings-card { grid-column: 1 / -1; }
.settings-section {
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr;
gap: 16px;
margin-bottom: 20px;
}
.form-group label {
display: block;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
color: #6b7280;
margin-bottom: 6px;
}
.form-group select,
.form-group input {
width: 100%;
background: #0a0e17;
border: 1px solid #1f2937;
border-radius: 8px;
padding: 8px 12px;
color: #e0e3eb;
font-size: 14px;
outline: none;
}
.form-group select:focus,
.form-group input:focus { border-color: #3b82f6; }
.btn-row {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.btn {
padding: 10px 20px;
border-radius: 10px;
font-size: 13px;
font-weight: 600;
border: none;
cursor: pointer;
transition: opacity 0.2s;
}
.btn:hover { opacity: 0.85; }
.btn-primary { background: #3b82f6; color: #fff; }
.btn-danger { background: #ef4444; color: #fff; }
.btn-success { background: #22c55e; color: #fff; }
.btn-secondary { background: #1f2937; color: #e0e3eb; }
.message {
margin-top: 12px;
padding: 10px 14px;
border-radius: 8px;
font-size: 13px;
display: none;
}
.message.ok { background: #14532d; color: #4ade80; display: block; }
.message.err { background: #7f1d努力; color: #f87171; display: block; }
/* Trade log */
.trade-log { margin-top: 16px; }
.trade-entry {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid #1f2937;
font-size: 13px;
}
.trade-entry:last-child { border-bottom: none; }
.trade-entry .side { font-weight: 700; font-size: 11px; padding: 2px 8px; border-radius: 4px; }
.trade-entry .side.TAKE_PROFIT { background: #14532d; color: #4ade80; }
.trade-entry .side.BUY { background: #1a3a2a; color: #4ade80; }
.trade-entry .pnl { font-weight: 700; }
.trade-entry .pnl.pos { color: #4ade80; }
.trade-entry .pnl.neg { color: #f87171; }
@media (max-width: 768px) {
.grid-top { grid-template-columns: 1fr; }
.settings-section { grid-template-columns: 1fr 1fr; }
}
/* Kronos advisor */
.kronos-card { grid-column: 1 / -1; }
.kronos-meta { display: flex; gap: 12px; font-size: 12px; align-items: center; }
.kronos-meta code { background: #1f2937; padding: 2px 6px; border-radius: 4px; font-size: 11px; }
.kronos-grid { display: flex; flex-direction: column; gap: 16px; }
.kronos-block {
background: #1f2937;
border: 1px solid #374151;
border-radius: 8px;
padding: 12px 16px;
}
.kronos-block-label {
font-size: 12px;
color: #9ca3af;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 8px;
}
.kronos-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; }
.kronos-cell { min-width: 0; }
.kronos-cell .muted { font-size: 11px; color: #6b7280; margin-bottom: 2px; }
.kronos-value { font-size: 18px; font-weight: 600; color: #e0e3eb; }
.kronos-value.up { color: #4ade80; }
.kronos-value.down { color: #f87171; }
.kronos-value.paused { color: #fbbf24; }
.kronos-value.disabled { color: #6b7280; }
.badge-off { background: #4b5563; color: #d1d5db; }
.badge-on { background: #14532d; color: #4ade80; }
.badge-live { background: #7c2d12; color: #fdba74; }
.badge.paused { background: #7c2d12; color: #fbbf24; }
@media (max-width: 768px) {
.kronos-row { grid-template-columns: 1fr 1fr; }
.kronos-meta { flex-wrap: wrap; gap: 6px; }
}
</style>
</head>
<body>
<div class="header">
<h1>📊 Grid Bot</h1>
<div id="modeBadge" class="badge badge-demo">Демо режим</div>
</div>
<!-- Top stats -->
<div class="grid-top">
<div class="card">
<div class="card-label">
<span id="balanceLabel">Баланс USDT</span>
<span id="balanceStatus" class="balance-status" style="display:none;"></span>
</div>
<div class="card-value" id="balance">—</div>
<div class="card-sub" id="balanceSub">USDT</div>
<div id="balanceExtra" class="balance-extra" style="display:none;"></div>
</div>
<div class="card" id="accountCard" style="display:none;">
<div class="card-label">
<span>Аккаунт</span>
<span id="accountStatus" class="balance-status" style="display:none;"></span>
</div>
<div class="card-value" id="accountBrief" style="font-size:18px;">—</div>
<div class="card-sub" id="accountMain">—</div>
<div id="accountExtra" class="balance-extra" style="display:none;"></div>
</div>
<div class="card">
<div class="card-label">Цена BTC</div>
<div class="card-value" id="btcPrice">—</div>
<div class="card-sub" id="btcChange">—</div>
<div id="staleWarn" class="stale-warn" style="display:none;"></div>
</div>
<div class="card" id="quoteCard" style="display:none;">
<div class="card-label">Инструмент</div>
<div class="card-value" id="quoteName" style="font-size:18px;">—</div>
<div class="card-sub" id="quoteMarket">—</div>
<div id="quoteExtra" class="balance-extra" style="display:none;"></div>
</div>
<div class="card">
<div class="card-label">PNL</div>
<div class="card-value" id="pnl">0.00</div>
<div class="card-sub" id="pnlPct">0 сделок</div>
</div>
<div class="card" id="bidAskCard" style="display:none;">
<div class="card-label">Bid / Ask</div>
<div class="card-value" id="bidAsk" style="font-size:18px;">— / —</div>
<div class="card-sub" id="bidAskSpread">—</div>
</div>
</div>
<!-- Price chart -->
<div class="grid-top">
<div class="card chart-card">
<div class="grid-header">
<h2>📈 Цена BTC</h2>
<div class="chart-toolbar">
<div class="range-buttons" id="chartModeButtons">
<button type="button" data-mode="tick" class="active">Тик</button>
<button type="button" data-mode="candles" data-tf="1">1м</button>
<button type="button" data-mode="candles" data-tf="5">5м</button>
<button type="button" data-mode="candles" data-tf="15">15м</button>
<button type="button" data-mode="candles" data-tf="60">1ч</button>
<button type="button" data-mode="candles" data-tf="1440">1д</button>
</div>
<div class="range-buttons" id="rangeButtons">
<button type="button" data-range="1h" class="active">1ч</button>
<button type="button" data-range="6h">6ч</button>
<button type="button" data-range="24h">24ч</button>
<button type="button" data-range="all">Все</button>
</div>
</div>
</div>
<div class="chart-wrap">
<canvas id="btcChart"></canvas>
</div>
<div class="chart-legend">
<span><span class="legend-dot" style="background:#60a5fa"></span>Цена</span>
<span><span class="legend-dot" style="background:#22c55e"></span>BUY уровень</span>
<span><span class="legend-dot" style="background:#f87171"></span>SELL уровень</span>
<span><span class="legend-dot" style="background:#fbbf24"></span>Сделка</span>
<span><span class="legend-dot" style="background:#a78bfa"></span>Ребилд</span>
<span id="chartInfo" style="margin-left:auto;color:#6b7280;font-weight:500;"></span>
</div>
</div>
</div>
<!-- Grid -->
<div class="grid-top">
<div class="card grid-card">
<div class="grid-header">
<h2>Grid ордера</h2>
<span id="gridInfo">—</span>
</div>
<div style="display:grid; grid-template-columns: 50px 1fr 80px 80px 60px; gap:8px; font-size:10px; font-weight:700; text-transform:uppercase; color:#6b7280; margin-bottom:8px; padding:0 12px;">
<div>Side</div><div style="text-align:right">Price</div><div style="text-align:right">Dist%</div><div style="text-align:right">Qty</div><div style="text-align:right">Status</div>
</div>
<div id="gridBody">—</div>
</div>
</div>
<!-- Settings -->
<div class="grid-top">
<div class="card settings-card">
<div class="grid-header">
<h2>⚙️ Настройки</h2>
</div>
<div class="settings-section">
<div class="form-group">
<label>Режим</label>
<select id="selMode">
<option value="true">Демо (100 USDT)</option>
<option value="false">Реальный счёт</option>
</select>
</div>
<div class="form-group">
<label>Ордеров в сетке</label>
<select id="selLevels">
<option value="5">5 (5 вверх / 5 вниз)</option>
<option value="10">10 (10 вверх / 10 вниз)</option>
</select>
</div>
<div class="form-group">
<label>Take Profit %</label>
<input id="inpTP" type="number" step="0.1" min="0.1" max="20" value="0.5">
</div>
<div class="form-group">
<label>Шаг сетки %</label>
<input id="inpStep" type="number" step="0.01" min="0.01" max="5" value="0.5">
</div>
</div>
<div class="form-group" style="margin-bottom:16px;">
<label>Символ</label>
<select id="selSymbol">
<option value="BTC-USDT.IMEX" selected>BTC / USDT (IMEX)</option>
</select>
</div>
<div class="btn-row">
<button class="btn btn-primary" id="btnApply">Применить</button>
<button class="btn btn-danger" id="btnReset">Сбросить демо</button>
<button class="btn btn-success" id="btnStart" onclick="startBot()">▶ Старт</button>
<button class="btn btn-secondary" id="btnStop" onclick="stopBot()">⏸ Стоп</button>
</div>
<div class="message" id="msg"></div>
</div>
</div>
<!-- Kronos advisor -->
<div class="grid-top">
<div class="card settings-card kronos-card" id="kronosCard">
<div class="grid-header">
<h2>🧠 Kronos Advisor <span id="kronosStatusBadge" class="badge badge-off">OFF</span></h2>
<div class="kronos-meta">
<span class="muted">model:</span> <code id="kronosModel">—</code>
<span class="muted">TF:</span> <code id="kronosTf">—</code>
<span class="muted">horizon:</span> <code id="kronosHorizon">—</code>
</div>
</div>
<div class="kronos-grid">
<!-- Совет -->
<div class="kronos-block">
<div class="kronos-block-label">Текущий совет</div>
<div class="kronos-row">
<div class="kronos-cell">
<div class="muted">Шаг сетки</div>
<div class="kronos-value" id="kronosStep">—</div>
<div class="muted" id="kronosStepSub">vs base —</div>
</div>
<div class="kronos-cell">
<div class="muted">Центр</div>
<div class="kronos-value" id="kronosCenter">—</div>
<div class="muted" id="kronosCenterSub">bias —</div>
</div>
<div class="kronos-cell">
<div class="muted">Ожид. диапазон</div>
<div class="kronos-value" id="kronosRange">—</div>
<div class="muted" id="kronosRangeSub">conf —</div>
</div>
<div class="kronos-cell">
<div class="muted">Пауза</div>
<div class="kronos-value" id="kronosPause">—</div>
<div class="muted" id="kronosPauseSub">—</div>
</div>
</div>
</div>
<!-- Применено ботом -->
<div class="kronos-block">
<div class="kronos-block-label">Применено ботом</div>
<div class="kronos-row">
<div class="kronos-cell">
<div class="muted">Эфф. шаг</div>
<div class="kronos-value" id="kronosEffStep">—</div>
<div class="muted" id="kronosEffStepSub">—</div>
</div>
<div class="kronos-cell">
<div class="muted">Эфф. центр</div>
<div class="kronos-value" id="kronosEffCenter">—</div>
<div class="muted" id="kronosEffCenterSub">—</div>
</div>
<div class="kronos-cell">
<div class="muted">Источник</div>
<div class="kronos-value" id="kronosSource">—</div>
<div class="muted" id="kronosSourceSub">—</div>
</div>
<div class="kronos-cell">
<div class="muted">Сгенерирован</div>
<div class="kronos-value" id="kronosGenAt">—</div>
<div class="muted" id="kronosGenAtSub">—</div>
</div>
</div>
</div>
</div>
<div class="btn-row" style="margin-top: 12px;">
<button class="btn btn-secondary" id="btnKronosRefresh" onclick="kronosRefresh()">⟳ Обновить совет</button>
<span class="muted" id="kronosLastMsg" style="margin-left:12px; font-size:12px;">—</span>
</div>
</div>
</div>
<!-- Trade log -->
<div class="grid-top">
<div class="card settings-card" style="grid-column: 1 / -1">
<h2 style="font-size:16px; margin-bottom:12px;">История торговли</h2>
<div id="tradeLog"><div style="color:#6b7280; font-size:13px;">Пока нет сделок</div></div>
</div>
</div>
<script src="/static/chart.umd.min.js"></script>
<script src="/static/chartjs-plugin-annotation.min.js"></script>
<script>
let stateTimer = null;
let lastPrice = 0;
let startPrice = 0;
let botStartTime = 0;
let priceChart = null;
let currentRange = "1h";
let chartMode = "tick"; // "tick" | "candles"
let chartTf = 60; // minutes: 1, 5, 15, 60, 1440
async function loadSettings() {
const r = await fetch("/api/settings");
const s = await r.json();
document.getElementById("selMode").value = String(s.demo_mode);
document.getElementById("selLevels").values = String(s.grid_levels);
document.getElementById("inpTP").value = (s.take_profit_percent * 100).toFixed(2);
document.getElementById("inpStep").value = (s.step_percent * 100).toFixed(2);
updateModeBadge(s.demo_mode);
}
async function loadStatus() {
try {
const r = await fetch("/api/status");
if (!r.ok) return;
const d = await r.json();
document.getElementById("modeBadge").textContent = d.mode === "demo" ? "Демо режим" : "Режим реальной торговли";
document.getElementById("modeBadge").className = "badge " + (d.mode === "demo" ? "badge-demo" : "badge-live");
// Balance
const balEl = document.getElementById("balance");
const balSubEl = document.getElementById("balanceSub");
const balLabelEl = document.getElementById("balanceLabel");
const balStatusEl = document.getElementById("balanceStatus");
const balExtraEl = document.getElementById("balanceExtra");
const cur = d.currency || (d.mode === "demo" ? "USDT" : "USDT");
const balValue = d.balance || 0;
balLabelEl.textContent = "Баланс " + cur;
balEl.textContent = balValue.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 4 });
if (d.mode === "demo") {
balSubEl.textContent = cur + " (виртуальный)";
balStatusEl.style.display = "none";
balExtraEl.style.display = "none";
} else {
// Live: status badge + other currencies
const stale = d.wallet_stale !== false; // default true if missing
const err = d.wallet_error;
balStatusEl.style.display = "inline-block";
if (err) {
balStatusEl.textContent = "🔴 " + (err.length > 30 ? err.slice(0, 30) + "…" : err);
balStatusEl.className = "balance-status error";
balSubEl.textContent = "Аккаунт недоступен" + (d.wallet_age_seconds ? ", " + d.wallet_age_seconds + "с" : "");
} else if (stale && d.wallet_age_seconds > 0) {
balStatusEl.textContent = "🟡 " + d.wallet_age_seconds + "с";
balStatusEl.className = "balance-status stale";
balSubEl.textContent = cur + " (кэш)";
} else {
balStatusEl.textContent = "🟢 live";
balStatusEl.className = "balance-status fresh";
balSubEl.textContent = cur + " (реальный)";
}
// Other currencies (excluding primary)
const balances = d.balances || [];
if (balances.length > 1) {
const rows = balances
.filter(b => b.currency.toUpperCase() !== cur.toUpperCase())
.filter(b => b.total > 0.0001) // hide dust
.sort((a, b) => b.total - a.total)
.map(b => `<div class="row${b.currency.toUpperCase() === cur.toUpperCase() ? " primary" : ""}">` +
`<span class="cur">${b.currency}</span>` +
`<span class="total">${b.total.toLocaleString(undefined, { maximumFractionDigits: 6 })}</span>` +
`</div>`)
.join("");
if (rows) {
balExtraEl.innerHTML = rows;
balExtraEl.style.display = "block";
} else {
balExtraEl.style.display = "none";
}
} else {
balExtraEl.style.display = "none";
}
}
// BTC price
const price = d.current_price || 0;
// Use server-side start_price/price_change_pct (authoritative, set on bot start,
// not reset on page reload). Falls back to client-computed values for first paint.
const serverStart = d.start_price && d.start_price > 0 ? d.start_price : startPrice;
if (startPrice === 0 && price > 0 && !d.start_price) {
startPrice = price;
botStartTime = Date.now();
} else if (d.start_price && d.start_price > 0) {
startPrice = d.start_price;
}
document.getElementById("btcPrice").textContent = formatPrice(price);
// Price change since bot started (server-authoritative)
const changeEl = document.getElementById("btcChange");
const startForCalc = serverStart || startPrice;
if (startForCalc > 0 && price > 0) {
const diff = price - startForCalc;
const pct = (diff / startForCalc) * 100;
const sign = diff >= 0 ? "+" : "";
const cls = diff >= 0 ? "up" : "down";
const arrow = diff >= 0 ? "▲" : "▼";
changeEl.textContent = arrow + " " + sign + pct.toFixed(2) + "% (" + sign + diff.toFixed(1) + ")";
changeEl.className = "price-change " + cls;
document.getElementById("btcPrice").className = "card-value " + cls;
} else {
changeEl.textContent = "—";
changeEl.className = "price-change";
}
lastPrice = price;
// Stale-grid warning (if a rebuild happened because price dropped without fills)
const warnEl = document.getElementById("staleWarn");
if (warnEl) {
if (d.grid_stale_rebuilds > 0) {
warnEl.textContent = "♻️ Сетка перестраивалась: " + d.grid_stale_rebuilds +
" раз (падение > " + (d.stale_threshold_pct || 0) + "% без сделок)";
warnEl.style.display = "block";
} else {
warnEl.style.display = "none";
}
}
if (d.position) {
const pct = d.position.pnl_pct || 0;
const el = document.getElementById("pnl");
el.textContent = (d.position.unrealized_pnl >= 0 ? "+" : "") + d.position.unrealized_pnl.toFixed(4) + " USDT";
el.className = "card-value " + (pct >= 0 ? "up" : "down");
document.getElementById("pnlPct").textContent = (pct >= 0 ? "+" : "") + pct.toFixed(2) + "%";
} else {
document.getElementById("pnl").textContent = "0.00";
document.getElementById("pnl").className = "card-value neutral";
document.getElementById("pnlPct").textContent = d.total_trades > 0 ? (d.total_trades + " сделок") : "Нет сделок";
}
// Account info (live mode only)
const accountCard = document.getElementById("accountCard");
if (accountCard && d.mode === "live" && d.account && d.account.brief_nm) {
accountCard.style.display = "block";
document.getElementById("accountBrief").textContent = "ID " + d.account.brief_nm;
const mainCur = d.account.main_curr || "USDT";
const active = d.account.active;
document.getElementById("accountMain").textContent = "Main: " + mainCur + (active ? " • актив" : "");
const accStatus = document.getElementById("accountStatus");
if (accStatus) {
accStatus.style.display = "inline-block";
accStatus.textContent = "🟢 live";
accStatus.className = "balance-status fresh";
}
} else if (accountCard) {
accountCard.style.display = "none";
}
// Quote info (security info: lot, min_step, market) — live mode only
const quoteCard = document.getElementById("quoteCard");
if (quoteCard && d.mode === "live" && d.quote_info && d.quote_info.lot) {
quoteCard.style.display = "block";
document.getElementById("quoteName").textContent = d.quote_info.short_name || d.symbol;
const lotNum = parseFloat(d.quote_info.lot);
const stepNum = parseFloat(d.quote_info.min_step);
document.getElementById("quoteMarket").textContent = d.quote_info.mkt_name + " • шаг " + stepNum + " • лот " + lotNum;
} else if (quoteCard) {
quoteCard.style.display = "none";
}
// Bid / Ask (live mode only)
const bidAskCard = document.getElementById("bidAskCard");
if (bidAskCard && d.mode === "live" && d.quote && d.quote.bbp && d.quote.bap) {
bidAskCard.style.display = "block";
document.getElementById("bidAsk").textContent =
d.quote.bbp.toLocaleString(undefined, { maximumFractionDigits: 2 }) + " / " +
d.quote.bap.toLocaleString(undefined, { maximumFractionDigits: 2 });
const spread = d.quote.bap - d.quote.bbp;
const spreadPct = (spread / d.quote.bbp) * 100;
const sign = spread >= 0 ? "+" : "";
document.getElementById("bidAskSpread").textContent = "спред: " + sign + spread.toFixed(2) + " (" + spreadPct.toFixed(3) + "%)";
} else if (bidAskCard) {
bidAskCard.style.display = "none";
}
} catch(e) {}
}
async function loadGrid() {
try {
const r = await fetch("/api/grid");
if (!r.ok) return;
const d = await r.json();
document.getElementById("gridInfo").textContent =
d.total_orders + " ордеров | шаг " + d.step_percent.toFixed(2) + "% | TP " + d.take_profit_percent + "%";
const body = document.getElementById("gridBody");
if (!d.levels || d.levels.length === 0) {
body.innerHTML = '<div style="color:#6b7280; font-size:13px;">Запустите бота для генерации сетки</div>';
return;
}
let html = "";
d.levels.forEach(function(l) {
const dist = Math.abs(l.level_id) * d.step_percent;
const filledClass = l.filled ? "filled" : "pending";
const filledText = l.filled ? "✓" : "○";
html += '<div class="grid-row ' + l.side + '">' +
'<div class="grid-col-side" style="color:' + (l.side==="BUY"?"#22c55e":"#f87171") + '">' + l.side + '</div>' +
'<div class="grid-col-price">' + formatPrice(l.price) + '</div>' +
'<div class="grid-col-dist">' + dist.toFixed(2) + '%</div>' +
'<div class="grid-col-qty">' + l.qty + '</div>' +
'<div class="grid-col-f ' + filledClass + '">' + filledText + '</div>' +
'</div>';
});
body.innerHTML = html;
} catch(e) {}
}
async function loadLogs() {
try {
const r = await fetch("/api/logs");
if (!r.ok) return;
const d = await r.json();
const log = document.getElementById("tradeLog");
if (!d.trades || d.trades.length === 0) {
log.innerHTML = '<div style="color:#6b7280; font-size:13px;">Пока нет сделок</div>';
return;
}
let html = "";
d.trades.slice(-20).reverse().forEach(function(t) {
const pnlPos = t.pnl_usdc >= 0;
html += '<div class="trade-entry">' +
'<span class="side ' + t.side + '">' + (t.side === "TAKE_PROFIT" ? "TP" : t.side) + '</span>' +
'<span style="color:#6b7280;">' + formatPrice(t.entry) + ' → ' + formatPrice(t.exit) + '</span>' +
'<span>' + t.qty + '</span>' +
'<span class="pnl ' + (pnlPos ? "pos" : "neg") + '">' + (pnlPos ? "+" : "") + t.pnl_usdc.toFixed(4) + ' (' + (pnlPos ? "+" : "") + t.pnl_pct + '%)</span>' +
'</div>';
});
log.innerHTML = html;
} catch(e) {}
}
function formatPrice(p) {
if (!p) return "—";
return p.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 8 });
}
function updateModeBadge(demo_mode) {
const badge = document.getElementById("modeBadge");
if (demo_mode) {
badge.textContent = "Демо режим";
badge.className = "badge badge-demo";
} else {
badge.textContent = "Режим реальной торговли";
badge.className = "badge badge-live";
}
}
function showMsg(text, ok) {
const el = document.getElementById("msg");
el.textContent = text;
el.className = "message " + (ok ? "ok" : "err");
setTimeout(function() { el.style.display = "none"; }, 3000);
}
async function applySettings() {
const payload = {
demo_mode: document.getElementById("selMode").value === "true",
grid_levels: parseInt(document.getElementById("selLevels").value),
take_profit_percent: parseFloat(document.getElementById("inpTP").value) / 100,
step_percent: parseFloat(document.getElementById("inpStep").value) / 100,
};
const r = await fetch("/api/settings", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(payload),
});
if (r.ok) {
updateModeBadge(payload.demo_mode);
showMsg("Настройки применены", true);
loadStatus();
loadGrid();
} else {
showMsg("Ошибка применения настроек", false);
}
}
async function startBot() {
const r = await fetch("/api/bot/start", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({ dry_run: false }),
});
const d = await r.json();
if (r.ok) {
showMsg("Бот запущен", true);
} else {
showMsg(d.error || "Ошибка", false);
}
}
async function stopBot() {
const r = await fetch("/api/bot/stop", { method: "POST" });
if (r.ok) showMsg("Бот остановлен", true);
}
async function resetDemo() {
const r = await fetch("/api/reset", { method: "POST" });
if (r.ok) {
showMsg("Демо баланс сброшен", true);
loadStatus();
loadLogs();
}
}
document.getElementById("btnApply").addEventListener("click", applySettings);
document.getElementById("btnReset").addEventListener("click", resetDemo);
// ─── Price chart ──────────────────────────────────────────────
function buildLevelAnnotations(levels) {
// Horizontal dashed lines for each grid level
if (!levels || !levels.length) return {};
const ann = {};
levels.forEach((lv, i) => {
const key = "lvl" + i;
ann[key] = {
type: "line",
yMin: lv.price,
yMax: lv.price,
borderColor: lv.side === "BUY" ? "rgba(34,197,94,0.35)" : "rgba(248,113,113,0.35)",
borderWidth: 1,
borderDash: [4, 4],
label: {
display: true,
content: "$" + lv.price.toLocaleString(undefined, {maximumFractionDigits: 0}),
position: lv.side === "BUY" ? "start" : "end",
backgroundColor: lv.side === "BUY" ? "rgba(34,197,94,0.15)" : "rgba(248,113,113,0.15)",
color: lv.side === "BUY" ? "#22c55e" : "#f87171",
font: { size: 10, weight: "600" },
padding: 3,
}
};
});
return ann;
}
async function loadPriceChart() {
try {
const r = await fetch("/api/price-history?range=" + encodeURIComponent(currentRange));
if (!r.ok) return;
const d = await r.json();
const ctx = document.getElementById("btcChart");
if (!ctx) return;
// Build datasets
const pricePoints = (d.points || []).map(p => ({ x: p.t * 1000, y: p.p }));
const buyFills = (d.fills || []).filter(f => f.side === "BUY").map(f => ({ x: f.t * 1000, y: f.p }));
const sellFills = (d.fills || []).filter(f => f.side === "SELL").map(f => ({ x: f.t * 1000, y: f.p }));
const rebuilds = (d.rebuilds || []).map(p => ({ x: p.t * 1000, y: p.p }));
// If no data and bot not running, show placeholder
const noData = pricePoints.length === 0 && buyFills.length === 0 && sellFills.length === 0;
const infoEl = document.getElementById("chartInfo");
if (infoEl) {
if (noData && !d.bot_running) {
infoEl.textContent = "Бот не запущен — данных нет. Нажмите «▶» для старта.";
} else if (noData) {
infoEl.textContent = "Накопление данных… (первая точка через ~" + (d.sample_interval || 30) + " сек)";
} else {
const first = pricePoints[0] ? pricePoints[0].y : (d.start_price || 0);
const last = pricePoints.length ? pricePoints[pricePoints.length-1].y : (d.current_price || 0);
const change = first > 0 ? ((last - first) / first * 100) : 0;
const sign = change >= 0 ? "+" : "";
infoEl.textContent = "Δ " + sign + change.toFixed(2) + "% за " + d.range +
" (" + pricePoints.length + " точек)";
}
}
const annotations = buildLevelAnnotations(d.levels);
if (d.start_price > 0) {
annotations.startLine = {
type: "line",
yMin: d.start_price,
yMax: d.start_price,
borderColor: "rgba(167,139,250,0.5)",
borderWidth: 1,
borderDash: [2, 2],
label: {
display: true,
content: "start $" + d.start_price.toLocaleString(undefined, {maximumFractionDigits: 0}),
position: "center",
backgroundColor: "rgba(167,139,250,0.15)",
color: "#a78bfa",
font: { size: 10, weight: "600" },
padding: 3,
}
};
}
const data = {
datasets: [
{
label: "BTC",
data: pricePoints,
borderColor: "#60a5fa",
backgroundColor: "rgba(96,165,250,0.08)",
borderWidth: 2,
tension: 0.15,
pointRadius: 0,
pointHoverRadius: 4,
fill: true,
spanGaps: true,
order: 2,
},
{
label: "BUY fill",
data: buyFills,
type: "scatter",
backgroundColor: "#22c55e",
borderColor: "#22c55e",
pointRadius: 5,
pointHoverRadius: 7,
pointStyle: "triangle",
showLine: false,
order: 1,
},
{
label: "SELL fill",
data: sellFills,
type: "scatter",
backgroundColor: "#f87171",
borderColor: "#f87171",
pointRadius: 5,
pointHoverRadius: 7,
pointStyle: "rectRot",
showLine: false,
order: 1,
},
{
label: "Rebuild",
data: rebuilds,
type: "scatter",
backgroundColor: "#a78bfa",
borderColor: "#a78bfa",
pointRadius: 7,
pointHoverRadius: 9,
pointStyle: "star",
showLine: false,
order: 1,
},
]
};
const options = {
responsive: true,
maintainAspectRatio: false,
animation: { duration: 200 },
interaction: { mode: "index", intersect: false },
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: "rgba(17,24,39,0.95)",
borderColor: "#374151",
borderWidth: 1,
padding: 10,
titleColor: "#e0e3eb",
bodyColor: "#e0e3eb",
callbacks: {
title: (items) => {
if (!items.length) return "";
const ts = items[0].parsed.x;
const d = new Date(ts);
return d.toLocaleString("ru-RU", { hour: "2-digit", minute: "2-digit", second: "2-digit", day: "2-digit", month: "2-digit" });
},
label: (item) => {
const v = item.parsed.y;
if (v == null) return null;
return item.dataset.label + ": $" + v.toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2});
},
},
},
annotation: { annotations },
},
scales: {
x: {
type: "linear",
grid: { color: "rgba(255,255,255,0.04)" },
ticks: {
color: "#6b7280",
maxRotation: 0,
autoSkip: true,
maxTicksLimit: 8,
callback: (v) => {
const d = new Date(v);
if (currentRange === "1h") {
return d.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" });
} else if (currentRange === "6h") {
return d.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" });
} else {
return d.toLocaleString("ru-RU", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" });
}
},
},
},
y: {
position: "right",
grid: { color: "rgba(255,255,255,0.04)" },
ticks: {
color: "#6b7280",
callback: (v) => "$" + Number(v).toLocaleString(undefined, {maximumFractionDigits: 0}),
},
},
},
};
if (priceChart) {
priceChart.data = data;
priceChart.options = options;
priceChart.update("none");
} else {
if (window.ChartAnnotation && window.Chart && !Chart.registry.plugins.get("annotation")) {
Chart.register(window.ChartAnnotation);
}
priceChart = new Chart(ctx, { type: "line", data, options });
}
} catch (e) {
console.error("loadPriceChart failed", e);
}
}
async function loadCandlesChart() {
try {
const r = await fetch("/api/candles?tf=" + chartTf);
if (!r.ok) return;
const d = await r.json();
const ctx = document.getElementById("btcChart");
if (!ctx) return;
const candles = d.candles || [];
const infoEl = document.getElementById("chartInfo");
if (infoEl) {
if (candles.length === 0) {
infoEl.textContent = "Нет свечей за выбранный таймфрейм";
} else {
const first = candles[0].c;
const last = candles[candles.length - 1].c;
const change = first > 0 ? ((last - first) / first * 100) : 0;
const sign = change >= 0 ? "+" : "";
infoEl.textContent = "Δ " + sign + change.toFixed(2) + "% • " + candles.length + " свечей " + chartTf + "м";
}
}
// Build wick (low→high) and body (open→close) as line segments. Use segment.borderColor
// callback to color each segment by candle direction (green up, red down). This is the
// correct Chart.js 4 way to color individual line segments.
const wickData = [];
const bodyData = [];
candles.forEach(c => {
wickData.push({ x: c.t * 1000, y: c.l }, { x: c.t * 1000, y: c.h });
bodyData.push({ x: c.t * 1000, y: c.o }, { x: c.t * 1000, y: c.c });
});
const upColor = "rgba(34,197,94,1)";
const dnColor = "rgba(239,68,68,1)";
// For segment callbacks, p0DataIndex gives index of the segment's starting point.
// Each candle has 2 points (open, close or low, high), so map segment index → candle index.
const candleColor = (ctx) => {
const ci = Math.floor(ctx.p0DataIndex / 2);
const c = candles[ci];
return c && c.c >= c.o ? upColor : dnColor;
};
// Compute Y range
let yMin = Infinity, yMax = -Infinity;
candles.forEach(c => { if (c.l < yMin) yMin = c.l; if (c.h > yMax) yMax = c.h; });
const yPad = (yMax - yMin) * 0.05;
const data = {
datasets: [
{
label: "wick",
data: wickData,
borderColor: upColor,
borderWidth: 1.2,
pointRadius: 0,
tension: 0,
spanGaps: false,
segment: { borderColor: candleColor },
order: 3,
},
{
label: "body",
data: bodyData,
borderColor: upColor,
borderWidth: 6,
pointRadius: 0,
tension: 0,
spanGaps: false,
segment: { borderColor: candleColor },
order: 2,
},
]
};
const options = {
responsive: true,
maintainAspectRatio: false,
animation: { duration: 200 },
interaction: { mode: "index", intersect: false },
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: "rgba(17,24,39,0.95)",
borderColor: "#374151",
borderWidth: 1,
padding: 10,
titleColor: "#e0e3eb",
bodyColor: "#e0e3eb",
callbacks: {
title: (items) => {
if (!items.length) return "";
const ts = items[0].parsed.x;
const dt = new Date(ts);
return dt.toLocaleString("ru-RU", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" });
},
label: (item) => {
const c = candles.find(x => x.t * 1000 === item.parsed.x);
if (!c) return null;
const chg = c.c - c.o;
const chgPct = (chg / c.o) * 100;
const sign = chg >= 0 ? "+" : "";
return [
"O: $" + c.o.toLocaleString(undefined, { maximumFractionDigits: 2 }),
"H: $" + c.h.toLocaleString(undefined, { maximumFractionDigits: 2 }),
"L: $" + c.l.toLocaleString(undefined, { maximumFractionDigits: 2 }),
"C: $" + c.c.toLocaleString(undefined, { maximumFractionDigits: 2 }) + " (" + sign + chgPct.toFixed(2) + "%)",
"V: " + c.v.toLocaleString(undefined, { maximumFractionDigits: 0 }),
];
},
},
},
annotation: { annotations: {} },
},
scales: {
x: {
type: "linear",
grid: { color: "rgba(255,255,255,0.04)" },
ticks: {
color: "#6b7280",
maxRotation: 0,
autoSkip: true,
maxTicksLimit: 8,
callback: (v) => {
const dt = new Date(v);
if (chartTf >= 1440) {
return dt.toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit" });
}
return dt.toLocaleString("ru-RU", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" });
},
},
},
y: {
position: "right",
min: yMin - yPad,
max: yMax + yPad,
grid: { color: "rgba(255,255,255,0.04)" },
ticks: {
color: "#6b7280",
callback: (v) => "$" + Number(v).toLocaleString(undefined, { maximumFractionDigits: 0 }),
},
},
},
};
if (priceChart) {
priceChart.destroy();
priceChart = null;
}
priceChart = new Chart(ctx, { type: "line", data, options });
} catch (e) {
console.error("loadCandlesChart failed", e);
}
}
// Range buttons
document.querySelectorAll("#rangeButtons button").forEach(btn => {
btn.addEventListener("click", () => {
document.querySelectorAll("#rangeButtons button").forEach(b => b.classList.remove("active"));
btn.classList.add("active");
currentRange = btn.dataset.range;
if (chartMode === "tick") loadPriceChart();
});
});
// Chart mode buttons (tick vs candles)
document.querySelectorAll("#chartModeButtons button").forEach(btn => {
btn.addEventListener("click", () => {
document.querySelectorAll("#chartModeButtons button").forEach(b => b.classList.remove("active"));
btn.classList.add("active");
const mode = btn.dataset.mode;
if (mode === "tick") {
chartMode = "tick";
loadPriceChart();
} else {
chartMode = "candles";
chartTf = parseInt(btn.dataset.tf, 10);
loadCandlesChart();
}
});
});
// ─── Kronos advisor panel ────────────────────────────────────────────────────
let _kronosLastFetch = 0;
async function loadKronos() {
// Троттлинг: не чаще раза в 3 сек
const now = Date.now();
if (now - _kronosLastFetch < 3000) return;
_kronosLastFetch = now;
try {
const r = await fetch("/api/kronos");
if (!r.ok) return;
const d = await r.json();
renderKronos(d);
} catch (e) {
/* ignore */
}
}
function fmtPct(x, digits = 3) {
if (x === null || x === undefined || isNaN(x)) return "—";
return (x * 100).toFixed(digits) + "%";
}
function fmtUsd(x, digits = 0) {
if (!x || isNaN(x)) return "—";
return "$" + x.toLocaleString("en-US", { minimumFractionDigits: digits, maximumFractionDigits: digits });
}
function fmtTimeAgo(iso) {
if (!iso) return "—";
try {
const t = new Date(iso).getTime();
const sec = Math.max(0, Math.floor((Date.now() - t) / 1000));
if (sec < 60) return sec + "s ago";
if (sec < 3600) return Math.floor(sec / 60) + "m ago";
if (sec < 86400) return Math.floor(sec / 3600) + "h ago";
return Math.floor(sec / 86400) + "d ago";
} catch (e) { return "—"; }
}
function renderKronos(d) {
// Бейдж статуса
const badge = document.getElementById("kronosStatusBadge");
if (d.enabled && d.live_enabled) {
badge.textContent = "LIVE";
badge.className = "badge badge-live";
} else if (d.enabled) {
badge.textContent = "ADVISOR ON";
badge.className = "badge badge-on";
} else {
badge.textContent = "OFF";
badge.className = "badge badge-off";
}
// Мета
document.getElementById("kronosModel").textContent = d.model || "—";
document.getElementById("kronosTf").textContent = (d.tf_min || 0) + "m";
document.getElementById("kronosHorizon").textContent = ((d.pred_len || 0) * (d.tf_min || 0)) + "m";
const a = d.current_advice;
const last = d.last_applied;
const la_advice = last ? last.advice : null;
// Текущий совет
if (a) {
const baseStep = (last && last.base_step) || 0.005;
document.getElementById("kronosStep").textContent = fmtPct(a.step_percent, 3);
const stepEl = document.getElementById("kronosStep");
stepEl.className = "kronos-value";
if (a.step_percent > baseStep * 1.01) stepEl.classList.add("up");
else if (a.step_percent < baseStep * 0.99) stepEl.classList.add("down");
const delta = (a.step_percent / baseStep - 1) * 100;
document.getElementById("kronosStepSub").textContent =
"vs base " + fmtPct(baseStep, 3) + " (" + (delta >= 0 ? "+" : "") + delta.toFixed(1) + "%)";
const off = (a.center_offset_pct || 0) * 100;
document.getElementById("kronosCenter").textContent = (off >= 0 ? "+" : "") + off.toFixed(2) + "%";
const cEl = document.getElementById("kronosCenter");
cEl.className = "kronos-value";
if (a.bias === "up") cEl.classList.add("up");
else if (a.bias === "down") cEl.classList.add("down");
document.getElementById("kronosCenterSub").textContent = "bias: " + (a.bias || "—");
document.getElementById("kronosRange").textContent = fmtPct(a.expected_range_pct, 2);
document.getElementById("kronosRangeSub").textContent = "conf: " + (a.confidence || 0).toFixed(2);
document.getElementById("kronosPause").textContent = a.pause_grid ? "⏸ YES" : "▶ no";
const pEl = document.getElementById("kronosPause");
pEl.className = a.pause_grid ? "kronos-value paused" : "kronos-value";
} else {
["kronosStep","kronosCenter","kronosRange","kronosPause"].forEach(id => {
document.getElementById(id).textContent = "—";
document.getElementById(id).className = "kronos-value disabled";
});
document.getElementById("kronosStepSub").textContent = "совет отсутствует";
document.getElementById("kronosCenterSub").textContent = "—";
document.getElementById("kronosRangeSub").textContent = "—";
document.getElementById("kronosPauseSub").textContent = "—";
}
// Пауза
if (d.paused_now) {
const left = Math.max(0, d.pause_until - Date.now() / 1000);
document.getElementById("kronosPauseSub").textContent =
"осталось " + Math.ceil(left / 60) + " мин";
} else {
document.getElementById("kronosPauseSub").textContent = "—";
}
// Применено ботом
if (last) {
document.getElementById("kronosEffStep").textContent = fmtPct(last.eff_step, 3);
document.getElementById("kronosEffStepSub").textContent = "base " + fmtPct(last.base_step, 3);
document.getElementById("kronosEffCenter").textContent = fmtUsd(last.eff_center, 0);
document.getElementById("kronosEffCenterSub").textContent = "base " + fmtUsd(last.base_center, 0);
document.getElementById("kronosSource").textContent = (la_advice || {}).source || "—";
document.getElementById("kronosSourceSub").textContent = "—";
document.getElementById("kronosGenAt").textContent = fmtTimeAgo((la_advice || {}).generated_at);
document.getElementById("kronosGenAtSub").textContent = "обновлён " + fmtTimeAgo(new Date(last.ts * 1000).toISOString());
} else {
["kronosEffStep","kronosEffCenter","kronosSource","kronosGenAt"].forEach(id => {
document.getElementById(id).textContent = "—";
});
["kronosEffStepSub","kronosEffCenterSub","kronosSourceSub","kronosGenAtSub"].forEach(id => {
document.getElementById(id).textContent = "совет ещё не применён";
});
}
}
async function kronosRefresh() {
const msg = document.getElementById("kronosLastMsg");
msg.textContent = "Запрашиваю новый совет...";
const btn = document.getElementById("btnKronosRefresh");
btn.disabled = true;
try {
const r = await fetch("/api/kronos/refresh", { method: "POST" });
const d = await r.json();
if (d.ok) {
msg.textContent = "✓ " + (d.message || "обновлено");
setTimeout(loadKronos, 1000);
} else {
msg.textContent = "✗ " + (d.error || "ошибка");
}
} catch (e) {
msg.textContent = "✗ " + e.message;
} finally {
btn.disabled = false;
setTimeout(() => { msg.textContent = "—"; }, 5000);
}
}
// Initial load
loadSettings();
loadStatus();
loadGrid();
loadLogs();
loadPriceChart();
loadKronos();
// Status / grid / logs: every 3 s
setInterval(function() {
loadStatus();
loadGrid();
loadLogs();
loadKronos();
}, 3000);
// Chart: every 10 s
setInterval(loadPriceChart, 10000);
</script>
</body>
</html>