1467 lines
52 KiB
Plaintext
1467 lines
52 KiB
Plaintext
<!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.not-placed { opacity: 0.4; background: #1a1d24; border-left-color: #4b5563; }
|
||
.grid-row.not-placed .grid-col-side,
|
||
.grid-row.not-placed .grid-col-price,
|
||
.grid-row.not-placed .grid-col-qty { color: #6b7280; }
|
||
.grid-col-f.not-placed { color: #6b7280; font-size: 10px; }
|
||
.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 isPlaced = (l.placed === true);
|
||
const filledClass = l.filled ? "filled" : (isPlaced ? "pending" : "not-placed");
|
||
const filledText = l.filled ? "✓" : (isPlaced ? "○" : "not placed");
|
||
const rowClass = 'grid-row ' + l.side + (isPlaced ? '' : ' not-placed');
|
||
const sideColor = isPlaced ? (l.side==="BUY" ? "#22c55e" : "#f87171") : "#6b7280";
|
||
html += '<div class="' + rowClass + '">' +
|
||
'<div class="grid-col-side" style="color:' + sideColor + '">' + 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>
|