| | |
| | | .instance-card .top-row { |
| | | display: flex; justify-content: space-between; align-items: center; |
| | | } |
| | | .instance-card .key { |
| | | font-size: 11px; color: var(--dim); font-family: "SF Mono", "Consolas", monospace; |
| | | .instance-card .alias-line { |
| | | display: flex; align-items: center; gap: 4px; min-width: 0; |
| | | } |
| | | .instance-card .contract { |
| | | font-size: 14px; font-weight: 600; margin-top: 2px; |
| | | .instance-card .alias-text { |
| | | font-size: 14px; font-weight: 600; |
| | | overflow: hidden; text-overflow: ellipsis; white-space: nowrap; |
| | | } |
| | | .instance-card .alias-text.placeholder { color: var(--dim); font-weight: 400; } |
| | | .instance-card .alias-edit { |
| | | background: none; border: none; cursor: pointer; color: var(--dim); |
| | | font-size: 11px; line-height: 1; padding: 2px 4px; border-radius: 4px; flex-shrink: 0; |
| | | } |
| | | .instance-card .alias-edit:hover { color: var(--accent); background: rgba(59,130,246,0.12); } |
| | | .instance-card .md5 { |
| | | font-size: 11px; color: var(--dim); font-family: "SF Mono", "Consolas", monospace; |
| | | margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; |
| | | } |
| | | .instance-card .meta { |
| | | display: flex; gap: 10px; margin-top: 6px; font-size: 11px; color: var(--dim); |
| | |
| | | } |
| | | .status-dot.active { background: var(--green); box-shadow: 0 0 6px var(--green); } |
| | | .status-dot.stopped { background: var(--danger); } |
| | | .status-dot.waiting { background: var(--warn); box-shadow: 0 0 6px var(--warn); } |
| | | .status-dot.offline { background: var(--dim); } |
| | | |
| | | .sidebar-footer { |
| | |
| | | display: flex; align-items: center; gap: 10px; |
| | | } |
| | | .main-header h2 { font-size: 17px; } |
| | | .main-header .header-alias { |
| | | display: inline-block; max-width: 160px; vertical-align: middle; |
| | | overflow: hidden; text-overflow: ellipsis; white-space: nowrap; |
| | | background: #eef4ff; color: var(--accent); |
| | | border: 1px solid rgba(59,130,246,0.25); |
| | | font-size: 11px; font-weight: 500; padding: 2px 8px; border-radius: 10px; |
| | | margin-right: 6px; |
| | | } |
| | | .main-header .state-badge { |
| | | font-size: 11px; padding: 2px 10px; border-radius: 10px; |
| | | font-weight: 600; |
| | |
| | | .btn-danger { background: var(--danger); color: #fff; } |
| | | .btn-outline { background: transparent; border: 1px solid var(--border); color: var(--text); } |
| | | .btn:disabled { opacity: .4; cursor: not-allowed; } |
| | | |
| | | /* ===== 手动控制面板 ===== */ |
| | | .manual-actions { display: flex; flex-wrap: wrap; gap: 8px; } |
| | | .manual-actions .btn { padding: 7px 14px; } |
| | | |
| | | /* ===== 内容区(Tabs) ===== */ |
| | | .content { flex: 1; overflow-y: auto; padding: 16px 24px; } |
| | |
| | | .modal h3 { font-size: 16px; margin-bottom: 12px; } |
| | | .modal p { font-size: 13px; color: var(--dim); margin-bottom: 20px; } |
| | | .modal .modal-btns { display: flex; justify-content: flex-end; gap: 8px; } |
| | | .modal input[type="text"] { |
| | | width: 100%; padding: 8px 10px; box-sizing: border-box; |
| | | border: 1px solid var(--border); border-radius: 6px; |
| | | font-size: 13px; outline: none; color: var(--text); background: var(--input-bg); |
| | | margin-bottom: 16px; |
| | | } |
| | | .modal input[type="text"]:focus { border-color: var(--accent); } |
| | | |
| | | /* Toast — 页面正中间,3s 后彻底隐藏 */ |
| | | .toast { |
| | |
| | | <h2 style="color:var(--dim)">← 选择一个实例</h2> |
| | | </div> |
| | | <div class="btn-group"> |
| | | <button class="btn btn-success" id="btnStart" disabled onclick="showConfirm('start')">▶ 启动</button> |
| | | <button class="btn btn-danger" id="btnStop" disabled onclick="showConfirm('stop')">⏹ 停止</button> |
| | | <button class="btn btn-success" id="btnStart" disabled onclick="showConfirm('start','启动')">▶ 启动</button> |
| | | <button class="btn btn-danger" id="btnStop" disabled onclick="showConfirm('stop','停止')">⏹ 停止</button> |
| | | </div> |
| | | </div> |
| | | |
| | |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- 详情模板(初始隐藏) --> |
| | | <div id="detailTemplate" style="display:none"> |
| | | <!-- 详情模板 — 原生 <template>:内容惰性不参与文档流,克隆后无重复 ID --> |
| | | <template id="detailTemplate"> |
| | | <!-- Tabs --> |
| | | <div class="tabs"> |
| | | <div class="tab active" data-tab="overview">概览</div> |
| | |
| | | <!-- 概览 Tab --> |
| | | <div class="tab-content active" data-tab="overview"> |
| | | <div class="stats-grid" id="statsGrid"></div> |
| | | <div class="chart-container"> |
| | | <h3>⚙️ 手动控制 <span style="font-size:11px;color:var(--dim);font-weight:400">(实时下发 MQ 指令,策略运行中可用)</span></h3> |
| | | <div class="manual-actions"> |
| | | <button class="btn btn-outline" id="btnCancelLongTpSl" disabled onclick="showConfirm('cancel-long-tp-sl','取消多仓止盈止损')">取消多仓止盈止损</button> |
| | | <button class="btn btn-outline" id="btnCancelShortTpSl" disabled onclick="showConfirm('cancel-short-tp-sl','取消空仓止盈止损')">取消空仓止盈止损</button> |
| | | <button class="btn btn-outline" id="btnRebuildLongTpSl" disabled onclick="showConfirm('rebuild-long-tp-sl','生成多仓止盈止损')">生成多仓止盈止损</button> |
| | | <button class="btn btn-outline" id="btnRebuildShortTpSl" disabled onclick="showConfirm('rebuild-short-tp-sl','生成空仓止盈止损')">生成空仓止盈止损</button> |
| | | <button class="btn btn-danger" id="btnCloseLong" disabled onclick="showConfirm('close-long','多单平仓')">多单平仓</button> |
| | | <button class="btn btn-danger" id="btnCloseShort" disabled onclick="showConfirm('close-short','空单平仓')">空单平仓</button> |
| | | </div> |
| | | </div> |
| | | <div class="chart-container"> |
| | | <h3>📈 盈亏趋势(最近 PNL_SNAPSHOT 事件)</h3> |
| | | <div class="pnl-summary" id="pnlSummary"></div> |
| | |
| | | <div id="configForm"></div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | |
| | | <!-- 确认弹窗 --> |
| | | <div class="modal-overlay" id="modalOverlay"> |
| | |
| | | <div class="modal-btns"> |
| | | <button class="btn btn-outline" onclick="closeModal()">取消</button> |
| | | <button class="btn" id="modalConfirmBtn" onclick="confirmAction()">确认</button> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- 别名编辑弹窗 --> |
| | | <div class="modal-overlay" id="aliasModalOverlay"> |
| | | <div class="modal"> |
| | | <h3>✎ 编辑别名</h3> |
| | | <p>为实例设置便于识别的显示名(留空保存可清除别名)</p> |
| | | <input id="aliasInput" type="text" maxlength="64" placeholder="例如:ETH_USDT" |
| | | onkeydown="if(event.key==='Enter'){saveAlias();} else if(event.key==='Escape'){closeAliasModal();}"> |
| | | <div class="modal-btns"> |
| | | <button class="btn btn-outline" onclick="closeAliasModal()">取消</button> |
| | | <button class="btn btn-success" onclick="saveAlias()">保存</button> |
| | | </div> |
| | | </div> |
| | | </div> |
| | |
| | | |
| | | // ==================== 工具 ==================== |
| | | function $(id) { return document.getElementById(id); } |
| | | |
| | | /** HTML 转义 — 所有来自服务端/JAR 的动态字符串插值前必须使用,防止属性逃逸注入 */ |
| | | const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ( |
| | | { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] |
| | | )); |
| | | function toast(msg, type) { |
| | | const t = $('toast'); |
| | | t.textContent = msg; |
| | |
| | | } |
| | | |
| | | // ==================== API 请求 ==================== |
| | | const API_TIMEOUT_MS = 10000; |
| | | |
| | | async function api(url, opts) { |
| | | // 超时保护:10s 未响应则中断请求,避免页面无限等待 |
| | | const ctrl = new AbortController(); |
| | | const timer = setTimeout(() => ctrl.abort(), API_TIMEOUT_MS); |
| | | try { |
| | | const res = await fetch(url, opts); |
| | | const res = await fetch(url, Object.assign({}, opts, { signal: ctrl.signal })); |
| | | // 未登录/无权限:引导回登录页 |
| | | if (res.status === 401 || res.status === 403) { |
| | | window.location.href = '/login'; |
| | | throw new Error('未登录或会话已过期'); |
| | | } |
| | | // 非 2xx 或非 JSON 响应(如安全框架拦截返回的 HTML):给出明确错误 |
| | | const ct = res.headers.get('content-type') || ''; |
| | | if (!res.ok || ct.indexOf('application/json') === -1) { |
| | | throw new Error('服务器异常 (' + res.status + ')'); |
| | | } |
| | | const data = await res.json(); |
| | | if (data.code !== 0 && data.code !== 200) throw new Error(data.msg || '请求失败'); |
| | | return data.data !== undefined ? data.data : data.msg; |
| | | } catch (e) { |
| | | if (e.name === 'AbortError') { |
| | | $('connStatus').innerHTML = '🔴 离线'; |
| | | throw new Error('请求超时,请检查网络'); |
| | | } |
| | | if (e.message.includes('Failed to fetch') || e.message.includes('NetworkError')) { |
| | | $('connStatus').innerHTML = '🔴 离线'; |
| | | } |
| | | throw e; |
| | | } finally { |
| | | clearTimeout(timer); |
| | | } |
| | | } |
| | | |
| | |
| | | } |
| | | instances = newInstances; |
| | | renderInstanceList(); |
| | | // 实例数据已更新,同步刷新选中实例的概览统计与参数页状态条 |
| | | // 实例数据已更新,同步刷新选中实例的概览统计、参数页状态条与操作按钮 |
| | | if (selectedMd5 && instances[selectedMd5]) { |
| | | renderStats(instances[selectedMd5]); |
| | | renderConfigStatusBar(); |
| | | updateActionButtons(instances[selectedMd5]); |
| | | } else if (selectedMd5) { |
| | | // 选中实例已离线:禁用操作按钮,详情区保留最后快照 |
| | | updateActionButtons(null); |
| | | } |
| | | } catch (e) { |
| | | // 静默失败,保留旧数据 |
| | |
| | | container.innerHTML = keys.map(k => { |
| | | const i = instances[k]; |
| | | const isActive = i.state === 'ACTIVE' || i.state === 'OPENING'; |
| | | const dotClass = i.state === 'STOPPED' ? 'stopped' : isActive ? 'active' : 'offline'; |
| | | const dotClass = i.state === 'STOPPED' ? 'stopped' |
| | | : isActive ? 'active' |
| | | : i.state === 'WAITING_KLINE' ? 'waiting' : 'offline'; |
| | | const pnl = fmtPnl(i.cumulativePnl); |
| | | const selected = k === selectedMd5 ? ' selected' : ''; |
| | | // 第一行显示别名;未设置时回退显示合约名(占位样式) |
| | | const hasAlias = !!i.aliasName; |
| | | const aliasText = hasAlias ? i.aliasName : (i.contract || '未命名实例'); |
| | | return ` |
| | | <div class="instance-card${selected}" onclick="selectInstance('${k}')" data-md5="${k}"> |
| | | <div class="instance-card${selected}" onclick="selectInstance('${esc(k)}')" data-md5="${esc(k)}"> |
| | | <div class="top-row"> |
| | | <span class="key">${i.contract || '—'}</span> |
| | | <span class="status-dot ${dotClass}" title="${i.state || 'UNKNOWN'}"></span> |
| | | <span class="alias-line"> |
| | | <span class="alias-text${hasAlias ? '' : ' placeholder'}" title="${esc(aliasText)}">${esc(aliasText)}</span> |
| | | <button class="alias-edit" title="编辑别名" onclick="event.stopPropagation(); showAliasModal('${esc(k)}')">✎</button> |
| | | </span> |
| | | <span class="status-dot ${dotClass}" title="${esc(i.state || 'UNKNOWN')}"></span> |
| | | </div> |
| | | <div class="contract">${k.substring(0, 16)}...</div> |
| | | <div class="md5">${esc(k.substring(0, 16))}...</div> |
| | | <div class="meta"> |
| | | <span>轮次 ${i.currentRound ?? 0}</span> |
| | | <span class="pnl ${pnl.cls}">${pnl.text}</span> |
| | | <span class="pnl ${pnl.cls}">${esc(pnl.text)}</span> |
| | | <span>${relativeTime(i.lastSeen)}</span> |
| | | </div> |
| | | </div>`; |
| | |
| | | const content = $('mainContent'); |
| | | const tmpl = $('detailTemplate'); |
| | | if (!content.querySelector('.tabs')) { |
| | | content.innerHTML = tmpl.innerHTML; |
| | | // 先清空空状态占位,再克隆 <template> 内容(惰性片段,克隆后无重复 ID) |
| | | content.innerHTML = ''; |
| | | content.appendChild(tmpl.content.cloneNode(true)); |
| | | // 绑定 tab 切换 |
| | | content.querySelectorAll('.tab').forEach(t => { |
| | | t.addEventListener('click', () => switchTab(t.dataset.tab)); |
| | | }); |
| | | } |
| | | |
| | | // 更新 header |
| | | const header = $('mainHeader'); |
| | | header.querySelector('.instance-title').innerHTML = ` |
| | | <h2>${inst.contract || '—'} <span style="font-size:12px;color:var(--dim);font-weight:400">${md5.substring(0,12)}...</span></h2> |
| | | <span class="state-badge ${sanitizeState(inst.state)}">${inst.state || 'UNKNOWN'}</span> |
| | | <button class="btn btn-outline" onclick="refreshEvents()" style="font-size:11px;padding:3px 10px">🔄 刷新</button> |
| | | `; |
| | | $('btnStart').disabled = false; |
| | | $('btnStop').disabled = false; |
| | | // 更新 header(含别名,未设置别名时不显示) |
| | | renderHeader(inst); |
| | | |
| | | // 更新概览统计 |
| | | renderStats(inst); |
| | |
| | | // 加载事件(内部会渲染事件表格 + PNL 图表) |
| | | await loadEvents(md5); |
| | | |
| | | // 事件加载完成后重算统计卡:账户总权益依赖最新 PNL_SNAPSHOT |
| | | if (selectedMd5 === md5 && instances[md5]) { |
| | | renderStats(instances[md5]); |
| | | } |
| | | |
| | | // 策略参数表单:仅全量加载(切换实例)时渲染,避免轮询清空用户正在编辑的内容 |
| | | if (full) { |
| | | loadConfig(md5); |
| | | } |
| | | } |
| | | |
| | | function renderHeader(inst) { |
| | | const header = $('mainHeader'); |
| | | const md5 = inst.apiKeyMd5; |
| | | // 别名徽标:仅当设置了别名时显示 |
| | | const aliasHtml = inst.aliasName |
| | | ? `<span class="header-alias" title="${esc(inst.aliasName)}">${esc(inst.aliasName)}</span>` |
| | | : ''; |
| | | header.querySelector('.instance-title').innerHTML = ` |
| | | <h2>${aliasHtml}${esc(inst.contract || '—')} <span style="font-size:12px;color:var(--dim);font-weight:400">${esc(md5.substring(0, 12))}...</span></h2> |
| | | <span class="state-badge ${sanitizeState(inst.state)}">${esc(inst.state || 'UNKNOWN')}</span> |
| | | <button class="btn btn-outline" onclick="refreshEvents()" style="font-size:11px;padding:3px 10px">🔄 刷新</button> |
| | | `; |
| | | updateActionButtons(inst); |
| | | } |
| | | |
| | | function sanitizeState(s) { |
| | |
| | | } |
| | | |
| | | function switchTab(name) { |
| | | document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); |
| | | document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active')); |
| | | document.querySelector(`.tab[data-tab="${name}"]`).classList.add('active'); |
| | | document.querySelector(`.tab-content[data-tab="${name}"]`).classList.add('active'); |
| | | // 查询限定在 mainContent 内,避免误伤模板或其他区域 |
| | | const main = $('mainContent'); |
| | | main.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); |
| | | main.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active')); |
| | | main.querySelector(`.tab[data-tab="${name}"]`).classList.add('active'); |
| | | main.querySelector(`.tab-content[data-tab="${name}"]`).classList.add('active'); |
| | | // 概览 Tab 变为可见时校正图表尺寸(图表可能在隐藏容器中创建过) |
| | | if (name === 'overview' && pnlChart) pnlChart.resize(); |
| | | } |
| | | |
| | | // ==================== 统计卡片 ==================== |
| | |
| | | const pnl = fmtPnl(inst.cumulativePnl); |
| | | const principal = inst.principal ? parseFloat(inst.principal) : 0; |
| | | const cumpnl = inst.cumulativePnl ? parseFloat(inst.cumulativePnl) : 0; |
| | | const totalEquity = principal + cumpnl; |
| | | // 账户总权益:优先用最新 PNL_SNAPSHOT 的真实值(账户余额 + 浮动盈亏),无快照数据时回退估算 |
| | | let totalEquity = principal + cumpnl; |
| | | let equityFromSnapshot = false; |
| | | if (pnlData.length > 0) { |
| | | const latest = pnlData[pnlData.length - 1]; |
| | | if (isFinite(latest.totalEquity) && latest.totalEquity > 0 && isFinite(latest.unrealizedPnl)) { |
| | | totalEquity = latest.totalEquity + latest.unrealizedPnl; |
| | | equityFromSnapshot = true; |
| | | } |
| | | } |
| | | const roi = principal > 0 ? ((cumpnl / principal) * 100) : 0; |
| | | |
| | | $('statsGrid').innerHTML = ` |
| | | <div class="stat-card"> |
| | | <div class="label">累计已实现盈亏</div> |
| | | <div class="value ${pnl.cls}">${pnl.text}</div> |
| | | <div class="value ${pnl.cls}">${esc(pnl.text)}</div> |
| | | <div class="sub">USDT</div> |
| | | </div> |
| | | <div class="stat-card"> |
| | | <div class="label">初始本金</div> |
| | | <div class="value">${fmtNum(inst.principal)}</div> |
| | | <div class="value">${esc(fmtNum(inst.principal))}</div> |
| | | <div class="sub">USDT</div> |
| | | </div> |
| | | <div class="stat-card"> |
| | | <div class="label">估算总权益</div> |
| | | <div class="label">账户总权益</div> |
| | | <div class="value">${fmtNum(totalEquity)}</div> |
| | | <div class="sub">本金 + 已实现盈亏</div> |
| | | <div class="sub">${equityFromSnapshot ? '含浮动盈亏(最新快照)' : '本金 + 已实现盈亏(暂无快照)'}</div> |
| | | </div> |
| | | <div class="stat-card"> |
| | | <div class="label">收益率 (ROI)</div> |
| | |
| | | <div class="stat-card"> |
| | | <div class="label">当前轮次</div> |
| | | <div class="value">${inst.currentRound ?? 0}</div> |
| | | <div class="sub">杠杆 ${inst.leverage || '—'}x</div> |
| | | <div class="sub">杠杆 ${esc(inst.leverage || '—')}x</div> |
| | | </div> |
| | | <div class="stat-card"> |
| | | <div class="label">最后心跳</div> |
| | | <div class="value" style="font-size:16px">${relativeTime(inst.lastSeen)}</div> |
| | | <div class="sub">${inst.hostPort || '—'}</div> |
| | | <div class="sub">${esc(inst.hostPort || '—')}</div> |
| | | </div> |
| | | `; |
| | | } |
| | |
| | | time: e.eventTime || e.createTime, |
| | | cumulativePnl: parseFloat(payload.cumulativePnl || 0), |
| | | unrealizedPnl: parseFloat(payload.unrealizedPnl || 0), |
| | | totalEquity: parseFloat(payload.totalEquity || 0), |
| | | markPrice: parseFloat(payload.markPrice || 0) |
| | | totalEquity: parseFloat(payload.totalEquity || 0) |
| | | }; |
| | | }) |
| | | .slice(-50); |
| | | .slice(0, 50).reverse(); // 后端返回 DESC(最新在前),取最新 50 条并转时间正序 |
| | | |
| | | tbody.innerHTML = events.slice(0, 100).map(e => { |
| | | let payloadStr = e.payloadJson || ''; |
| | |
| | | const ts = e.eventTime || (e.createTime ? new Date(e.createTime).getTime() : null); |
| | | return ` |
| | | <tr> |
| | | <td class="relative-time" title="${new Date(ts).toLocaleString()}">${formatTime(ts)}</td> |
| | | <td><span class="event-tag ${e.eventType || ''}">${e.eventType || '—'}</span></td> |
| | | <td class="payload-preview" title="${payloadStr}">${shortPayload || '—'}</td> |
| | | <td class="relative-time" title="${esc(new Date(ts).toLocaleString())}">${formatTime(ts)}</td> |
| | | <td><span class="event-tag ${esc(e.eventType || '')}">${esc(e.eventType || '—')}</span></td> |
| | | <td class="payload-preview" title="${esc(payloadStr)}">${esc(shortPayload || '—')}</td> |
| | | </tr>`; |
| | | }).join(''); |
| | | |
| | |
| | | const items = [ |
| | | { label: '已实现盈亏', key: 'cumulativePnl', signed: true, digits: 4, fmt: v => (v >= 0 ? '+' : '') + v.toFixed(4) }, |
| | | { label: '未实现盈亏', key: 'unrealizedPnl', signed: true, digits: 4, fmt: v => (v >= 0 ? '+' : '') + v.toFixed(4) }, |
| | | { label: '总权益', key: 'totalEquity', signed: false, digits: 2, fmt: v => v.toFixed(2) }, |
| | | { label: '标记价', key: 'markPrice', signed: false, digits: 4, fmt: v => v.toFixed(4) } |
| | | { label: '总权益', key: 'totalEquity', signed: false, digits: 2, fmt: v => v.toFixed(2) } |
| | | ]; |
| | | |
| | | box.innerHTML = items.map(it => { |
| | |
| | | const dCls = d > 0 ? 'positive' : 'negative'; |
| | | deltaHtml = `<div class="s-delta ${dCls}">${d > 0 ? '▲' : '▼'} ${Math.abs(d).toFixed(it.digits)}</div>`; |
| | | } else { |
| | | deltaHtml = '<div class="s-delta">— 持平</div>'; |
| | | deltaHtml = '<div class="s-delta">持平</div>'; |
| | | } |
| | | } |
| | | return `<div class="pnl-summary-item"> |
| | |
| | | addPositionQuantity: 1, |
| | | maxPositionPerSide: 0, |
| | | addPositionStartThreshold: 1, |
| | | placeExcessTakeProfit: false, |
| | | placeExcessTakeProfit: 'off', |
| | | priceDriveEnabled: true, |
| | | dualStopLossEnabled: false, |
| | | longStopLossPrice: '', |
| | | shortStopLossPrice: '', |
| | | tpTriggerOppositeAddEnabled: false, |
| | | tpTriggerOppositeAddQuantity: 0, |
| | | }; |
| | | |
| | | async function loadConfig(md5) { |
| | |
| | | { key: 'addPositionQuantity', label: '加仓数量 (张)', type: 'number', placeholder: '1', jtype: 'int', group: 'addon' }, |
| | | { key: 'maxPositionPerSide', label: '单边最大仓位 (0=不限)', type: 'number', placeholder: '0', jtype: 'int', group: 'addon' }, |
| | | { key: 'addPositionStartThreshold', label: '加仓启动阈值 (次)', type: 'number', placeholder: '1', jtype: 'int', group: 'addon', tip: '前 N 次止损不触发加仓' }, |
| | | { key: 'tpTriggerOppositeAddEnabled', label: '止盈触发对向加仓', type: 'select', options: [{ v: 'false', l: '关闭' }, { v: 'true', l: '开启' }], group: 'addon', tip: '止盈单成交后触发对向加仓' }, |
| | | { key: 'tpTriggerOppositeAddQuantity', label: '对向加仓挂单数量 (张)', type: 'number', placeholder: '0', jtype: 'int', group: 'addon', tip: '止盈成交后在对向挂出的加仓张数' }, |
| | | |
| | | { key: 'rounds', label: '运行轮数 (0=不限)', type: 'number', placeholder: '0', jtype: 'int', group: 'run', tip: '盈利重启达此轮数后停止' }, |
| | | { key: 'placeExcessTakeProfit', label: '超额止盈', type: 'select', options: [{ v: 'false', l: '关闭' }, { v: 'true', l: '开启' }], group: 'run' }, |
| | | { key: 'placeExcessTakeProfit', label: '止盈模式', type: 'select', options: [{ v: 'off', l: '关闭' }, { v: 'concentrated', l: '集中止盈' }, { v: 'dispersed', l: '分散止盈' }], group: 'run', tip: '超出基础仓位的加仓部分如何挂止盈' }, |
| | | { key: 'priceDriveEnabled', label: '价格驱动', type: 'select', options: [{ v: 'true', l: '开启' }, { v: 'false', l: '关闭' }], group: 'run' }, |
| | | |
| | | { key: 'dualStopLossEnabled', label: '双开止损触发', type: 'select', options: [{ v: 'false', l: '关闭' }, { v: 'true', l: '开启' }], group: 'dual-sl', tip: '开启后按下方多空止损触发价初始化双向止损单,任一触发后重挂另一侧止损' }, |
| | | { key: 'longStopLossPrice', label: '多头止损触发价', type: 'number', step: 'any', placeholder: '如 2540', jtype: 'string', group: 'dual-sl', tip: '价格跌至该值时触发多头止损' }, |
| | | { key: 'shortStopLossPrice', label: '空头止损触发价', type: 'number', step: 'any', placeholder: '如 2650', jtype: 'string', group: 'dual-sl', tip: '价格涨至该值时触发空头止损' }, |
| | | ]; |
| | | |
| | | // 分组定义(渲染顺序) |
| | |
| | | { id: 'grid', name: '📐 网格参数' }, |
| | | { id: 'risk', name: '🛡️ 风控参数' }, |
| | | { id: 'addon', name: '📈 加仓 & 止损' }, |
| | | { id: 'dual-sl', name: '🎯 双开止损触发' }, |
| | | { id: 'run', name: '⚡ 运行控制' }, |
| | | ]; |
| | | |
| | |
| | | } |
| | | const pnl = fmtPnl(inst.cumulativePnl); |
| | | const items = [ |
| | | { label: '合约', value: inst.contract || '—', cls: '' }, |
| | | { label: '状态', value: inst.state || '—', cls: 'dim' }, |
| | | { label: '杠杆', value: (inst.leverage || '—') + 'x', cls: '' }, |
| | | { label: '合约', value: esc(inst.contract || '—'), cls: '' }, |
| | | { label: '状态', value: esc(inst.state || '—'), cls: 'dim' }, |
| | | { label: '杠杆', value: esc((inst.leverage || '—') + 'x'), cls: '' }, |
| | | { label: '当前轮次', value: inst.currentRound ?? 0, cls: '' }, |
| | | { label: '累计盈亏', value: pnl.text, cls: pnl.cls }, |
| | | { label: '初始本金', value: fmtNum(inst.principal), cls: '' }, |
| | | { label: '累计盈亏', value: esc(pnl.text), cls: pnl.cls }, |
| | | { label: '初始本金', value: esc(fmtNum(inst.principal)), cls: '' }, |
| | | ]; |
| | | bar.innerHTML = items.map(i => |
| | | `<div class="config-status-item"><div class="label">${i.label}</div><div class="value ${i.cls}">${i.value}</div></div>` |
| | |
| | | } |
| | | |
| | | // ==================== 启停操作 ==================== |
| | | function showConfirm(action) { |
| | | |
| | | // 按实例状态联动启用/禁用 启动/停止 按钮,防止误触(JAR 的 START 会平仓重建策略) |
| | | function updateActionButtons(inst) { |
| | | const startBtn = $('btnStart'); |
| | | const stopBtn = $('btnStop'); |
| | | // 手动控制按钮(止盈止损/平仓):需策略运行中才有持仓/挂单可操作 |
| | | const manualIds = ['btnCancelLongTpSl', 'btnCancelShortTpSl', 'btnRebuildLongTpSl', |
| | | 'btnRebuildShortTpSl', 'btnCloseLong', 'btnCloseShort']; |
| | | const setManual = (disabled) => { |
| | | manualIds.forEach(id => { const b = $(id); if (b) b.disabled = disabled; }); |
| | | }; |
| | | if (!inst) { |
| | | startBtn.disabled = true; |
| | | stopBtn.disabled = true; |
| | | setManual(true); |
| | | return; |
| | | } |
| | | const running = inst.state === 'ACTIVE' || inst.state === 'OPENING' || inst.state === 'WAITING_KLINE'; |
| | | startBtn.disabled = running; // 运行中不可重复启动 |
| | | stopBtn.disabled = !running; // 未运行不可停止 |
| | | setManual(!running); // 仅运行中可下发手动指令 |
| | | } |
| | | |
| | | function showConfirm(action, label) { |
| | | if (!selectedMd5) return; |
| | | pendingAction = action; |
| | | const verb = action === 'start' ? '启动' : '停止'; |
| | | pendingAction = { path: action, label: label || action }; |
| | | const verb = pendingAction.label; |
| | | const isDanger = action === 'stop' || action === 'close-long' || action === 'close-short'; |
| | | $('modalTitle').textContent = `确认${verb}`; |
| | | $('modalMsg').textContent = `确定要${verb}实例 ${selectedMd5.substring(0, 12)}... 的策略吗?`; |
| | | $('modalMsg').textContent = `确定要对实例 ${selectedMd5.substring(0, 12)}... 执行「${verb}」吗?`; |
| | | $('modalConfirmBtn').textContent = verb; |
| | | $('modalConfirmBtn').className = 'btn ' + (action === 'start' ? 'btn-success' : 'btn-danger'); |
| | | $('modalConfirmBtn').className = 'btn ' + (isDanger ? 'btn-danger' : 'btn-success'); |
| | | $('modalOverlay').classList.add('show'); |
| | | } |
| | | |
| | |
| | | pendingAction = null; |
| | | } |
| | | |
| | | // ==================== 别名编辑 ==================== |
| | | let aliasEditingMd5 = null; |
| | | |
| | | function showAliasModal(md5) { |
| | | aliasEditingMd5 = md5; |
| | | const inst = instances[md5]; |
| | | $('aliasInput').value = inst && inst.aliasName ? inst.aliasName : ''; |
| | | $('aliasModalOverlay').classList.add('show'); |
| | | $('aliasInput').focus(); |
| | | } |
| | | |
| | | function closeAliasModal() { |
| | | $('aliasModalOverlay').classList.remove('show'); |
| | | aliasEditingMd5 = null; |
| | | } |
| | | |
| | | async function saveAlias() { |
| | | if (!aliasEditingMd5) return; |
| | | const md5 = aliasEditingMd5; |
| | | const alias = $('aliasInput').value.trim(); |
| | | try { |
| | | await api(API + '/alias?apiKeyMd5=' + encodeURIComponent(md5) |
| | | + '&alias=' + encodeURIComponent(alias), { method: 'POST' }); |
| | | toast(alias ? '别名已保存' : '别名已清除', 'success'); |
| | | closeAliasModal(); |
| | | await refreshInstanceList(); |
| | | // 编辑的是当前选中实例时,同步刷新详情头部别名 |
| | | if (selectedMd5 === md5 && instances[md5]) { |
| | | renderHeader(instances[md5]); |
| | | } |
| | | } catch (e) { |
| | | toast(e.message, 'error'); |
| | | } |
| | | } |
| | | |
| | | async function confirmAction() { |
| | | if (!pendingAction || !selectedMd5) return; |
| | | const action = pendingAction; |
| | | closeModal(); |
| | | |
| | | try { |
| | | const msg = await api(API + '/' + action + '?apiKeyMd5=' + encodeURIComponent(selectedMd5), { method: 'POST' }); |
| | | const msg = await api(API + '/' + action.path + '?apiKeyMd5=' + encodeURIComponent(selectedMd5), { method: 'POST' }); |
| | | toast(msg || '指令已发送', 'success'); |
| | | // 延迟刷新等待 ACK |
| | | setTimeout(refreshInstanceList, 2000); |