Files
beast-trader/dashboard/index.html

835 lines
43 KiB
HTML
Raw 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="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=no">
<meta name="theme-color" content="#0d1117">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="manifest" href="manifest.json">
<title>Beast Trader</title>
<style>
*{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent}
:root{
--bg:#0d1117;--surface:#161b22;--surface2:#21262d;
--border:#30363d;--text:#e6edf3;--text2:#8b949e;--text3:#6e7681;
--green:#3fb950;--red:#f85149;--yellow:#d29922;--blue:#58a6ff;
}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:var(--bg);color:var(--text);font-size:14px;padding-bottom:70px;overflow-x:hidden}
.header{padding:16px 16px 8px;display:flex;justify-content:space-between;align-items:center;border-bottom:0.5px solid var(--border);background:var(--surface)}
.header h1{font-size:18px;font-weight:500}
.header .sub{font-size:12px;color:var(--text2)}
.st-dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:6px}
.st-ok{background:var(--green)}.st-warn{background:var(--yellow)}.st-err{background:var(--red)}
.tabs{display:flex;background:var(--surface);border-bottom:0.5px solid var(--border);position:sticky;top:0;z-index:10}
.tab{flex:1;text-align:center;padding:12px;font-size:13px;color:var(--text2);cursor:pointer;border-bottom:2px solid transparent}
.tab.active{color:var(--blue);border-bottom-color:var(--blue)}
.tc{display:none;padding:12px 16px}.tc.active{display:block}
.mg{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:12px}
.mc{background:var(--surface);border-radius:10px;padding:12px;border:0.5px solid var(--border)}
.mc .l{font-size:11px;color:var(--text3);margin-bottom:4px}
.mc .v{font-size:18px;font-weight:500}.v.gn{color:var(--green)}.v.rd{color:var(--red)}.v.bl{color:var(--blue)}.v.yl{color:var(--yellow)}
.ca{background:var(--surface);border-radius:10px;padding:12px;margin-bottom:12px;border:0.5px solid var(--border);overflow:hidden}
.ca .ct{font-size:13px;font-weight:500;margin-bottom:8px;display:flex;justify-content:space-between}
svg{width:100%;height:auto}
.sr-badge{display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:6px;font-size:12px;font-weight:500}
.sr-support{background:rgba(63,185,80,0.15);color:var(--green)}
.sr-resist{background:rgba(248,81,73,0.15);color:var(--red)}
.sr-price{background:rgba(88,166,255,0.15);color:var(--blue)}
.sr-row{display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap}
.fl{display:flex;flex-direction:column;gap:6px;margin-bottom:12px}
.fi{background:var(--surface);border-radius:8px;padding:10px 12px;border:0.5px solid var(--border);display:flex;justify-content:space-between;align-items:center}
.fi .nm{font-size:13px;font-weight:500}.fi .ds{font-size:11px;color:var(--text3);margin-top:2px}
.fi .st{font-size:12px;font-weight:500;padding:3px 8px;border-radius:5px}
.st-p{background:rgba(63,185,80,0.2);color:var(--green)}
.st-f{background:rgba(248,81,73,0.2);color:var(--red)}
.cb{padding:12px;border-radius:10px;margin-bottom:12px;text-align:center;font-size:15px;font-weight:500}
.cb.rdy{background:rgba(63,185,80,0.15);color:var(--green);border:0.5px solid var(--green)}
.cb.wt{background:rgba(210,153,34,0.15);color:var(--yellow);border:0.5px solid var(--yellow)}
.cb.no{background:rgba(248,81,73,0.1);color:var(--text3);border:0.5px solid var(--border)}
.ftr{position:fixed;bottom:0;left:0;right:0;background:var(--surface);border-top:0.5px solid var(--border);padding:8px 16px;display:flex;justify-content:space-between;align-items:center;z-index:20}
.ftr .fi2{font-size:11px;color:var(--text3)}
.rf{background:var(--surface2);border:0.5px solid var(--border);color:var(--text);padding:6px 16px;border-radius:8px;font-size:13px;cursor:pointer}
.sym{display:flex;gap:6px;margin-bottom:12px}
.sb{flex:1;padding:8px;text-align:center;border-radius:8px;border:0.5px solid var(--border);font-size:13px;cursor:pointer;color:var(--text2);background:var(--surface)}
.sb.ac{border-color:var(--blue);color:var(--blue);background:rgba(88,166,255,0.1)}
.ta{font-size:16px}.ta.up{color:var(--green)}.ta.dn{color:var(--red)}.ta.nt{color:var(--text3)}
.leg{display:flex;gap:12px;font-size:11px;color:var(--text3);margin-bottom:4px}
.leg span{display:flex;align-items:center;gap:4px}
.dot{width:8px;height:8px;border-radius:50%;display:inline-block}.dot.gn{background:var(--green)}.dot.rd{background:var(--red)}
.ld{text-align:center;padding:30px;color:var(--text3);font-size:14px}
.er{text-align:center;padding:20px;color:var(--red);font-size:13px}
.sec-title{font-size:12px;font-weight:500;color:var(--text2);margin:8px 0 4px}
.tr{background:var(--surface);border-radius:8px;padding:10px 12px;margin-bottom:6px;border:0.5px solid var(--border);font-size:12px}
.tr .tp{display:flex;justify-content:space-between;align-items:center;margin-bottom:4px}
.tr .td{display:flex;gap:16px;color:var(--text3);font-size:11px}
.tr .pnl{font-weight:600;font-size:13px}
.tbl{width:100%;border-collapse:collapse;font-size:11px}
.tbl th{color:var(--text3);font-weight:400;text-align:left;padding:4px 8px;border-bottom:0.5px solid var(--border)}
.tbl td{padding:6px 8px;border-bottom:0.5px solid var(--border);color:var(--text2)}
.tbl .num{text-align:right}
.tbl .win{color:var(--green)}.tbl .lose{color:var(--red)}
.pos-badge{display:inline-block;padding:2px 8px;border-radius:4px;font-size:10px;font-weight:500}
.pos-long{background:rgba(63,185,80,0.2);color:var(--green)}
.pos-short{background:rgba(248,81,73,0.2);color:var(--red)}
.empty-state{text-align:center;padding:24px;color:var(--text3);font-size:13px}
.g3{grid-template-columns:1fr 1fr 1fr}
.g4{grid-template-columns:1fr 1fr 1fr 1fr}
</style>
</head>
<body>
<div class="header">
<div>
<h1>Beast Trader</h1>
<div class="sub">v2.2d · Dashboard V1.2 · 直连 Binance</div>
</div>
<div><span class="st-dot st-warn" id="stDot"></span><span style="font-size:12px;color:var(--text2)" id="stTxt">初始化</span></div>
</div>
<div id="csBanner" style="display:none;background:rgba(248,81,73,0.15);border:1px solid var(--red);border-radius:8px;padding:8px 12px;margin-bottom:8px;font-size:12px;color:var(--red);text-align:center">
⚠️ CloudStudio 不支持 HTTPS→HTTP 请求,请用手机浏览器打开<br>
<a href="http://43.163.225.30:9000/" style="color:var(--green);text-decoration:underline">http://43.163.225.30:9000/</a>
</div>
<div class="tabs">
<div class="tab active" onclick="swTab('st')">结构分析</div>
<div class="tab" onclick="swTab('diag')">信号诊断</div>
<div class="tab" onclick="swTab('trade')">交易监测</div>
</div>
<div class="tc active" id="tab-st">
<div class="sym">
<div class="sb ac" onclick="swSym('ETH/USDT')">ETH/USDT</div>
<div class="sb" onclick="swSym('BTC/USDT')">BTC/USDT</div>
</div>
<div class="mg" id="mGrid">
<div class="mc"><div class="l">当前价格</div><div class="v bl" id="cPrice">--</div></div>
<div class="mc"><div class="l">D1 趋势</div><div class="v" id="cTrend"><span class="ta nt"></span> <span>计算中</span></div></div>
<div class="mc"><div class="l">支撑</div><div class="v gn" id="cSup">--</div></div>
<div class="mc"><div class="l">阻力</div><div class="v rd" id="cRes">--</div></div>
</div>
<div class="sr-row" id="srRow"></div>
<div class="ca">
<div class="ct"><span id="chP">ETH/USDT</span><span style="color:var(--text3);font-size:11px">最近 48 根 1H K线</span></div>
<div class="leg"><span><span class="dot gn"></span>Swing High</span><span><span class="dot rd"></span>Swing Low</span></div>
<svg viewBox="0 0 340 160" id="chSvg"><rect width="340" height="160" fill="var(--surface)" rx="6"/><text x="170" y="80" text-anchor="middle" fill="var(--text3)" font-size="12">获取数据中...</text></svg>
</div>
<div class="ca">
<div class="ct"><span>条件检查</span><span style="color:var(--text3);font-size:11px">实时</span></div>
<div id="condCard"><div class="ld">加载中...</div></div>
</div>
</div>
<div class="tc" id="tab-diag">
<div style="margin-bottom:8px;font-size:13px;color:var(--text2)">ETH/USDT · 实时过滤条件检查</div>
<div id="conclBar"></div>
<div class="fl" id="fList"><div class="ld">加载中...</div></div>
</div>
<div class="tc" id="tab-trade">
<div class="mg g4" id="accGrid">
<div class="mc"><div class="l">当前余额</div><div class="v bl" id="accBalance">--</div></div>
<div class="mc"><div class="l">总收益</div><div class="v" id="accPnl">--</div></div>
<div class="mc"><div class="l">胜率</div><div class="v" id="accWinRate">--</div></div>
<div class="mc"><div class="l">交易数</div><div class="v" id="accTrades">--</div></div>
</div>
<div style="display:flex;gap:16px;margin-bottom:8px;font-size:11px;color:var(--text3)">
<span id="accStrategy">策略: --</span>
<span id="accUpdate">更新: --</span>
</div>
<div class="sec-title">📌 当前持仓</div>
<div id="openPos"><div class="ld">加载中...</div></div>
<div class="sec-title">📋 近期交易 (<span id="tradeTotal">0</span>笔)</div>
<div style="overflow-x:auto">
<table class="tbl"><thead>
<tr><th>时间</th><th>方向</th><th>入场价</th><th>出场价</th><th class="num">盈亏</th><th>原因</th></tr>
</thead><tbody id="tradeBody"><tr><td colspan="6" class="empty-state">暂无交易记录</td></tr></tbody></table>
</div>
<div class="sec-title" style="margin-top:12px">📊 每日盈亏 (近30天)</div>
<div class="ca">
<svg viewBox="0 0 340 120" id="pnlSvg"><rect width="340" height="120" fill="var(--surface)" rx="6"/><text x="170" y="60" text-anchor="middle" fill="var(--text3)" font-size="12">暂无数据</text></svg>
</div>
<div class="sec-title">📉 盈亏分布</div>
<div class="ca">
<svg viewBox="0 0 340 100" id="distSvg"><rect width="340" height="100" fill="var(--surface)" rx="6"/><text x="170" y="50" text-anchor="middle" fill="var(--text3)" font-size="12">暂无数据</text></svg>
</div>
<div class="sec-title" style="margin-top:12px">📊 回测年化表现2021-2026</div>
<div style="font-size:12px;color:var(--text2);margin-bottom:12px">
<table style="width:100%;border-collapse:collapse;font-size:12px">
<tr style="background:var(--surface2)">
<th style="padding:6px 8px;text-align:left;border-bottom:1px solid var(--border)">年份</th>
<th style="padding:6px 8px;text-align:right;border-bottom:1px solid var(--border)">交易数</th>
<th style="padding:6px 8px;text-align:right;border-bottom:1px solid var(--border)">收益率</th>
<th style="padding:6px 8px;text-align:right;border-bottom:1px solid var(--border)">终值</th>
</tr>
<tr><td style="padding:6px 8px">2021</td><td style="padding:6px 8px;text-align:right">172</td><td style="padding:6px 8px;text-align:right;color:var(--up)">+251.16%</td><td style="padding:6px 8px;text-align:right">$35,116</td></tr>
<tr style="background:var(--surface2)"><td style="padding:6px 8px">2022</td><td style="padding:6px 8px;text-align:right">204</td><td style="padding:6px 8px;text-align:right;color:var(--up)">+110.91%</td><td style="padding:6px 8px;text-align:right">$21,091</td></tr>
<tr><td style="padding:6px 8px">2023</td><td style="padding:6px 8px;text-align:right">182</td><td style="padding:6px 8px;text-align:right;color:var(--up)">+49.35%</td><td style="padding:6px 8px;text-align:right">$14,935</td></tr>
<tr style="background:var(--surface2)"><td style="padding:6px 8px">2024</td><td style="padding:6px 8px;text-align:right">232</td><td style="padding:6px 8px;text-align:right;color:var(--up)">+185.84%</td><td style="padding:6px 8px;text-align:right">$28,584</td></tr>
<tr><td style="padding:6px 8px">2025</td><td style="padding:6px 8px;text-align:right">221</td><td style="padding:6px 8px;text-align:right;color:var(--up)">+608.24%</td><td style="padding:6px 8px;text-align:right">$70,824</td></tr>
<tr style="background:var(--surface2)"><td style="padding:6px 8px">2026</td><td style="padding:6px 8px;text-align:right">54</td><td style="padding:6px 8px;text-align:right;color:var(--dn)">-11.87%</td><td style="padding:6px 8px;text-align:right">$8,813</td></tr>
</table>
<div style="margin-top:6px;padding:6px 8px;background:var(--surface2);border-radius:4px">
<b>v2.2d 全周期 (2021-2026) 总收益:+205,684%</b> · CAGR 309.01% · Sharpe 1.03 · 最大回撤20.58%
</div>
</div>
</div>
<div class="ftr">
<span class="fi2" id="ftInfo">上次更新: --</span>
<button class="rf" onclick="refresh()">刷新</button>
</div>
<script>
// ============================================================
// 指标计算(移植自 v2.2b 策略)
// ============================================================
function detectSwingPoints(high, low, window=5) {
const sh = new Array(high.length).fill(NaN);
const sl = new Array(low.length).fill(NaN);
for (let i = window; i < high.length - window; i++) {
const leftH = high.slice(i - window, i);
const rightH = high.slice(i + 1, i + window + 1);
if (high[i] > Math.max(...leftH) && high[i] > Math.max(...rightH)) sh[i] = high[i];
const leftL = low.slice(i - window, i);
const rightL = low.slice(i + 1, i + window + 1);
if (low[i] < Math.min(...leftL) && low[i] < Math.min(...rightL)) sl[i] = low[i];
}
return [sh, sl];
}
function buildStructure(high, low, close, sh, sl) {
const n = high.length;
const tu = new Array(n).fill(false);
const td = new Array(n).fill(false);
const sup = new Array(n).fill(NaN);
const res = new Array(n).fill(NaN);
const idm = new Array(n).fill(false);
const isp = new Array(n).fill(false);
const shP = [], slP = [];
for (let i = 0; i < n; i++) {
if (!isNaN(sh[i])) { shP.push(sh[i]); if (shP.length > 4) shP.shift(); }
if (!isNaN(sl[i])) { slP.push(sl[i]); if (slP.length > 4) slP.shift(); }
if (shP.length >= 2 && slP.length >= 2) {
if (shP[shP.length-1] > shP[shP.length-2] && slP[slP.length-1] > slP[slP.length-2]) tu[i] = true;
else if (shP[shP.length-1] < shP[shP.length-2] && slP[slP.length-1] < slP[slP.length-2]) td[i] = true;
else if (i > 0) { tu[i] = tu[i-1]; td[i] = td[i-1]; }
} else if (i > 0) { tu[i] = tu[i-1]; td[i] = td[i-1]; }
if (slP.length) sup[i] = slP[slP.length-1];
if (shP.length) res[i] = shP[shP.length-1];
const c = close[i];
if (!isNaN(sup[i]) && !isNaN(res[i]) && res[i] - sup[i] > 0) {
const pct = (c - sup[i]) / (res[i] - sup[i]);
idm[i] = pct < 0.35;
isp[i] = pct > 0.65;
}
}
return {tu, td, sup, res, idm, isp};
}
function trendStrength(high, low, sh, sl, minStr=-0.20) {
const n = high.length;
const up = new Array(n).fill(NaN), dn = new Array(n).fill(NaN);
const supS = new Array(n).fill(false), sdn = new Array(n).fill(false);
const shP = [], slP = [];
for (let i = 0; i < n; i++) {
if (!isNaN(sh[i])) { shP.push(sh[i]); if (shP.length > 4) shP.shift(); }
if (!isNaN(sl[i])) { slP.push(sl[i]); if (slP.length > 4) slP.shift(); }
if (shP.length >= 2 && slP.length >= 2) {
const hhD = (shP[shP.length-1] - shP[shP.length-2]) / shP[shP.length-2];
const hlD = (slP[slP.length-1] - slP[slP.length-2]) / slP[slP.length-2];
up[i] = hhD + hlD;
dn[i] = -(hhD + hlD);
}
}
for (let i = 0; i < n; i++) {
supS[i] = !isNaN(up[i]) && up[i] > minStr;
sdn[i] = !isNaN(dn[i]) && dn[i] > minStr;
}
return {up, dn, supS, sdn};
}
function supportAlive(low, close, support) {
const n = low.length;
const r = new Array(n).fill(false);
let found = false;
for (let i = 0; i < n; i++) {
if (isNaN(support[i])) { r[i] = found; continue; }
const touched = low[i] <= support[i] * 1.005 && low[i] >= support[i] * 0.995;
const held = close[i] > support[i];
if (touched && held) found = true;
r[i] = found;
if (i >= 3 && !touched) {
let keep = false;
for (let j = Math.max(0, i-2); j <= i; j++) {
const tj = low[j] <= support[j] * 1.005 && low[j] >= support[j] * 0.995;
const hj = close[j] > support[j];
if (tj && hj) keep = true;
}
r[i] = keep;
}
}
return r;
}
function resistAlive(high, close, resist) {
const n = high.length;
const r = new Array(n).fill(false);
for (let i = 0; i < n; i++) {
if (isNaN(resist[i])) { r[i] = i > 0 ? r[i-1] : false; continue; }
let found = false;
for (let j = Math.max(0, i-2); j <= i; j++) {
const touched = high[j] >= resist[j] * 0.995 && high[j] <= resist[j] * 1.005;
const held = close[j] < resist[j];
if (touched && held) found = true;
}
r[i] = found;
}
return r;
}
function computeAll(pair, df1h, df4h, df1d) {
const params = {swingD1:10, swingH4:8, pbr:0.6, msd:0.50, tsm:-0.20};
// D1
const [sh1d, sl1d] = detectSwingPoints(df1d.high, df1d.low, params.swingD1);
const st1d = buildStructure(df1d.high, df1d.low, df1d.close, sh1d, sl1d);
const li1d = st1d.tu.length-1;
const trUp1d = st1d.tu[li1d], trDn1d = st1d.td[li1d];
// 4H
const [sh4h, sl4h] = detectSwingPoints(df4h.high, df4h.low, params.swingH4);
const st4h = buildStructure(df4h.high, df4h.low, df4h.close, sh4h, sl4h);
const ts4h = trendStrength(df4h.high, df4h.low, sh4h, sl4h, params.tsm);
const li4h = st4h.tu.length-1;
const sup4h = st4h.sup[li4h], res4h = st4h.res[li4h];
const idx4h = st4h.idm[li4h], isp4h = st4h.isp[li4h];
const sa4h = supportAlive(df4h.low, df4h.close, st4h.sup)[li4h];
const ra4h = resistAlive(df4h.high, df4h.close, st4h.res)[li4h];
const sUp4h = ts4h.supS[li4h], sDn4h = ts4h.sdn[li4h];
const tsUp = ts4h.up[li4h], tsDn = ts4h.dn[li4h];
// 1H
const lh = df1h.close.length-1;
const cPrice = df1h.close[lh];
const bod = df1h.close.map((c,i) => Math.abs(c - df1h.open[i]));
const tr = df1h.high.map((h,i) => Math.max(h - df1h.low[i], 0.0001));
const uw = df1h.high.map((h,i) => h - (df1h.close[i] > df1h.open[i] ? df1h.close[i] : df1h.open[i]));
const lw = df1h.high.map((h,i) => (df1h.close[i] > df1h.open[i] ? df1h.open[i] : df1h.close[i]) - df1h.low[i]);
const isPin = tr.map((t,i) => (uw[i] + lw[i]) / t > params.pbr);
const bp = isPin.map((p,i) => p && df1h.close[i] > df1h.open[i] && lw[i] > uw[i]);
const bsh = isPin.map((p,i) => p && df1h.close[i] < df1h.open[i] && uw[i] > lw[i]);
const be = df1h.close.map((c,i) => i>0 && c > df1h.open[i-1] && df1h.open[i] < df1h.close[i-1] && c > df1h.open[i]);
const bse = df1h.close.map((c,i) => i>0 && c < df1h.open[i-1] && df1h.open[i] > df1h.close[i-1] && c < df1h.open[i]);
const buS = bp.map((p,i) => p || (be[i]||false));
const beS = bsh.map((p,i) => p || (bse[i]||false));
// Distances
const lsd = !isNaN(sup4h) && sup4h > 0 ? (cPrice - sup4h) / cPrice : null;
const ssd = !isNaN(res4h) && res4h > 0 ? (res4h - cPrice) / cPrice : null;
const ldOk = lsd !== null && lsd <= params.msd && lsd > 0.003;
const sdOk = ssd !== null && ssd <= params.msd && ssd > 0.003;
// Zone info
let zoneW = null, posPct = null;
if (!isNaN(sup4h) && !isNaN(res4h) && sup4h > 0 && res4h > sup4h) {
zoneW = ((res4h - sup4h) / sup4h) * 100;
posPct = ((cPrice - sup4h) / (res4h - sup4h)) * 100;
}
// Swing points history
const sHighs = [], sLows = [];
for (let i = 0; i < sh4h.length; i++) {
if (!isNaN(sh4h[i])) sHighs.push({price:sh4h[i], time:df4h.time[i]});
if (!isNaN(sl4h[i])) sLows.push({price:sl4h[i], time:df4h.time[i]});
}
// Diagnosis
const longOk = trUp1d && idx4h && ldOk && sa4h && sUp4h;
const shortOk = trDn1d && isp4h && sdOk && ra4h && sDn4h;
return {
cPrice: Math.round(cPrice * 100) / 100,
sup4h: !isNaN(sup4h) ? Math.round(sup4h * 100) / 100 : null,
res4h: !isNaN(res4h) ? Math.round(res4h * 100) / 100 : null,
zoneW: zoneW !== null ? Math.round(zoneW * 100) / 100 : null,
posPct: posPct !== null ? Math.round(posPct * 10) / 10 : null,
trUp1d: trUp1d, trDn1d: trDn1d,
idx4h, isp4h, sa4h, ra4h, sUp4h, sDn4h,
tsUp: !isNaN(tsUp) ? Math.round(tsUp * 10000) / 100 : null,
tsDn: !isNaN(tsDn) ? Math.round(tsDn * 10000) / 100 : null,
lsd: lsd !== null ? Math.round(lsd * 10000) / 100 : null,
ssd: ssd !== null ? Math.round(ssd * 10000) / 100 : null,
ldOk, sdOk, longOk, shortOk,
buS: buS[lh], beS: beS[lh],
priceHist: df1h.close,
sHighs: sHighs.slice(-6), sLows: sLows.slice(-6),
};
}
// ============================================================
// 数据获取 (通过服务器后端 API)
// ============================================================
let cache = {};
let curSym = 'ETH/USDT';
const IS_CLOUDSTUDIO = window.location.hostname.includes('codebuddy.work') || window.location.hostname.includes('app.codebuddy');
const SERVER_URL = IS_CLOUDSTUDIO ? 'http://43.163.225.30:9000' : window.location.origin;
const API_KEY = 'beast2025';
async function fetchFromServer() {
const headers = { 'X-API-Key': API_KEY, 'Accept': 'application/json' };
const [structRes, diagRes] = await Promise.all([
fetch(SERVER_URL + '/api/market-structure', { headers }),
fetch(SERVER_URL + '/api/signal-diagnosis', { headers }),
]);
if (!structRes.ok || !diagRes.ok) throw new Error('API ' + structRes.status);
const struct = await structRes.json();
const diag = await diagRes.json();
return { struct, diag };
}
async function loadData() {
document.getElementById('stDot').className = 'st-dot st-warn';
document.getElementById('stTxt').textContent = '获取数据';
try {
const data = await fetchFromServer();
const symKey = curSym === 'ETH/USDT' ? 'eth' : 'btc';
cache[curSym] = data;
cache[curSym + '_key'] = symKey;
document.getElementById('stDot').className = 'st-dot st-ok';
document.getElementById('stTxt').textContent = '在线';
render();
} catch(e) {
document.getElementById('stDot').className = 'st-dot st-err';
if (e.message && (e.message.includes('Failed to fetch') || e.message.includes('NetworkError'))) {
document.getElementById('stTxt').textContent = IS_CLOUDSTUDIO ? 'HTTPS阻截' : '连接失败';
document.getElementById('csBanner').style.display = IS_CLOUDSTUDIO ? 'block' : 'none';
document.getElementById('mGrid').innerHTML = '<div class="er">⚠️ 无法连接服务器<br><small>请用手机浏览器直接打开<br><a href="http://43.163.225.30:9000/" style="color:var(--green)">http://43.163.225.30:9000/</a></small></div>';
document.getElementById('fList').innerHTML = '<div class="er">连接失败 — 请用直连 IP 访问</div>';
} else {
document.getElementById('stTxt').textContent = '离线';
document.getElementById('mGrid').innerHTML = '<div class="er">数据获取失败: ' + e.message + '</div>';
document.getElementById('fList').innerHTML = '<div class="er">连接失败</div>';
}
}
}
// ============================================================
// 渲染(适配服务器 API 格式)
// ============================================================
function render() {
const raw = cache[curSym];
if (!raw) return;
const key = cache[curSym + '_key'] || 'eth';
const st = raw.struct ? raw.struct[key] : null;
const di = raw.diag ? raw.diag[key] : null;
if (!st || !di) return;
// Price cards
document.getElementById('cPrice').textContent = st.current_price ? '$' + Number(st.current_price).toLocaleString('en-US',{minimumFractionDigits:2}) : '--';
const tEl = document.getElementById('cTrend');
if (st.trend_1d === 'up') tEl.innerHTML = '<span class="ta up">&#x2191;</span> 上升趋势';
else if (st.trend_1d === 'down') tEl.innerHTML = '<span class="ta dn">&#x2193;</span> 下降趋势';
else tEl.innerHTML = '<span class="ta nt">&#x2192;</span> 震荡';
document.getElementById('cSup').textContent = st.support ? '$' + Number(st.support).toLocaleString('en-US',{minimumFractionDigits:2}) : '--';
document.getElementById('cRes').textContent = st.resistance ? '$' + Number(st.resistance).toLocaleString('en-US',{minimumFractionDigits:2}) : '--';
// SR badges
let sr = '';
if (st.support) sr += '<span class="sr-badge sr-support">&#x25BC; 支撑 $' + Number(st.support).toFixed(2) + '</span>';
if (st.resistance) sr += '<span class="sr-badge sr-resist">&#x25B2; 阻力 $' + Number(st.resistance).toFixed(2) + '</span>';
if (st.zone_width_pct) sr += '<span class="sr-badge sr-price">&#x25C6; 带宽 ' + Number(st.zone_width_pct).toFixed(1) + '%</span>';
if (st.price_position_pct !== null && st.price_position_pct !== undefined) sr += '<span class="sr-badge sr-price">&#x25C6; 位置 ' + Number(st.price_position_pct).toFixed(0) + '%</span>';
document.getElementById('srRow').innerHTML = sr;
// Chart
try { renderChart(st); } catch(e) { console.error("Chart error:", e); }
// Condition cards
try { renderConditions(di, st); } catch(e) { console.error("Conditions error:", e); }
}
function renderChart(st) {
const hist = st.price_history_1h;
const p = hist ? hist.close : null;
const ts = hist ? hist.timestamps : null;
if (!p || p.length < 3) return;
const w = 340, h = 200, pad = 20, topPad = 22, botPad = 20;
const cw = w - pad*2, ch = h - topPad - botPad;
const vals = p.concat(st.support || []).concat(st.resistance || []);
const mn = Math.min(...vals), mx = Math.max(...vals);
const rg = (mx - mn) || 1;
const xS = (i) => pad + (i / (p.length - 1)) * cw;
const yS = (v) => topPad + ch - ((v - mn) / rg) * ch;
const pts = p.map((v,i) => xS(i).toFixed(0)+','+yS(v).toFixed(0)).join(' ');
let svg = '<rect width="'+w+'" height="'+h+'" fill="var(--surface)" rx="6"/>';
if (st.support && st.resistance && st.resistance > st.support) {
const supY = yS(st.support), resY = yS(st.resistance);
const zoneH = supY - resY;
svg += '<rect x="'+(w-12)+'" y="'+resY+'" width="6" height="'+zoneH+'" rx="3" fill="rgba(136,136,136,0.12)"/>';
const supplyH = zoneH * 0.35;
svg += '<rect x="'+(w-12)+'" y="'+resY+'" width="6" height="'+supplyH+'" rx="3" fill="rgba(248,81,73,0.2)"/>';
const demandH = zoneH * 0.35;
svg += '<rect x="'+(w-12)+'" y="'+(supY - demandH)+'" width="6" height="'+demandH+'" rx="3" fill="rgba(63,185,80,0.2)"/>';
svg += '<text x="'+pad+'" y="'+(resY + supplyH/2 + 3)+'" fill="var(--red)" font-size="9" opacity="0.6">供给区</text>';
svg += '<text x="'+pad+'" y="'+(supY - demandH/2 + 3)+'" fill="var(--green)" font-size="9" opacity="0.6">需求区</text>';
svg += '<line x1="'+pad+'" y1="'+resY.toFixed(0)+'" x2="'+(w-pad)+'" y2="'+resY.toFixed(0)+'" stroke="var(--red)" stroke-width="1" stroke-dasharray="4,3" opacity="0.8"/>';
svg += '<text x="'+(w-pad-14)+'" y="'+(resY-4)+'" text-anchor="end" fill="var(--red)" font-size="11">阻力 $'+Number(st.resistance).toFixed(0)+'</text>';
svg += '<line x1="'+pad+'" y1="'+supY.toFixed(0)+'" x2="'+(w-pad)+'" y2="'+supY.toFixed(0)+'" stroke="var(--green)" stroke-width="1" stroke-dasharray="4,3" opacity="0.8"/>';
svg += '<text x="'+(w-pad-14)+'" y="'+(supY-4)+'" text-anchor="end" fill="var(--green)" font-size="11">支撑 $'+Number(st.support).toFixed(0)+'</text>';
}
svg += '<polyline points="'+pts+'" fill="none" stroke="var(--blue)" stroke-width="1.5"/>';
const lx = xS(p.length-1), ly = yS(p[p.length-1]);
svg += '<circle cx="'+lx.toFixed(0)+'" cy="'+ly.toFixed(0)+'" r="3.5" fill="var(--blue)" stroke="var(--surface)" stroke-width="1.5"/>';
svg += '<text x="'+(lx).toFixed(0)+'" y="'+(ly-6).toFixed(0)+'" text-anchor="middle" fill="var(--blue)" font-size="10" font-weight="500">$'+Number(p[p.length-1]).toFixed(0)+'</text>';
if (st.swing_points) {
if (st.swing_points.highs) {
st.swing_points.highs.slice(-8).forEach(sp => {
const ix = p.findIndex(c => Math.abs(c - sp.price) / Math.max(sp.price,1) < 0.008);
if (ix >= 0) {
const sx = xS(ix), sy = yS(sp.price);
svg += '<circle cx="'+sx.toFixed(0)+'" cy="'+sy.toFixed(0)+'" r="2.5" fill="var(--green)" opacity="0.6"/>';
}
});
}
if (st.swing_points.lows) {
st.swing_points.lows.slice(-8).forEach(sp => {
const ix = p.findIndex(c => Math.abs(c - sp.price) / Math.max(sp.price,1) < 0.008);
if (ix >= 0) {
const sx = xS(ix), sy = yS(sp.price);
svg += '<circle cx="'+sx.toFixed(0)+'" cy="'+sy.toFixed(0)+'" r="2.5" fill="var(--red)" opacity="0.6"/>';
}
});
}
}
svg += '<text x="'+pad+'" y="'+(topPad-4)+'" fill="var(--text3)" font-size="9">$'+Number(mx).toFixed(0)+'</text>';
svg += '<text x="'+pad+'" y="'+(h-botPad+14)+'" fill="var(--text3)" font-size="9">$'+Number(mn).toFixed(0)+'</text>';
if (ts && ts.length > 1) {
svg += '<text x="'+pad+'" y="'+(h-2)+'" fill="var(--text3)" font-size="9">'+(ts[0]||'').slice(0,11).replace(' ',' ')+'</text>';
svg += '<text x="'+(w-pad)+'" y="'+(h-2)+'" text-anchor="end" fill="var(--text3)" font-size="9">'+(ts[ts.length-1]||'').slice(0,11).replace(' ',' ')+'</text>';
}
document.getElementById('chSvg').innerHTML = svg;
document.getElementById('chP').textContent = curSym;
}
// ============================================================
// 条件卡片渲染V1.2 — 同时显示做多+做空两套条件)
// ============================================================
function renderConditions(di, st) {
const el = document.getElementById('condCard');
if (!di || !di.filters || !st) { el.innerHTML = '<div class="ld">加载中...</div>'; return; }
const f = di.filters;
const inSupply = f['in_supply_1h'] ? f['in_supply_1h'].pass : false;
const inDemand = f['in_demand_1h'] ? f['in_demand_1h'].pass : false;
const zoneLabel = inSupply ? '供给区' : (inDemand ? '需求区' : '中间区');
const zonePct = st.price_position_pct !== null && st.price_position_pct !== undefined ? st.price_position_pct : null;
const distToSup = st.current_price && st.support ? (st.current_price - st.support) : null;
const distToRes = st.resistance && st.current_price ? (st.resistance - st.current_price) : null;
const shortChecks = [
{key:'trend_down_1d', label:'D1 下降趋势'},
{key:'in_supply_1h', label:'在供给区'},
{key:'resistance_alive_1h', label:'阻力有效'},
{key:'strong_downtrend_4h', label:'4H 下降趋势'},
{key:'short_stop_distance', label:'止损距离合理'},
];
const longChecks = [
{key:'trend_up_1d', label:'D1 上升趋势'},
{key:'in_demand_1h', label:'在需求区'},
{key:'support_alive_1h', label:'支撑有效'},
{key:'strong_uptrend_4h', label:'4H 上升趋势'},
{key:'long_stop_distance', label:'止损距离合理'},
];
function renderCheckGroup(checks, title, titleColor) {
let gHtml = '<div style="margin-bottom:8px">';
gHtml += '<div style="font-size:12px;font-weight:500;color:'+titleColor+';margin-bottom:4px">'+title+'</div>';
gHtml += '<div style="display:flex;flex-wrap:wrap;gap:4px;margin-bottom:4px">';
let passN = 0;
checks.forEach(c => {
const fl = f[c.key];
if (!fl) return;
if (fl.pass) passN++;
const bg = fl.pass ? 'rgba(63,185,80,0.12)' : 'rgba(248,81,73,0.08)';
const cl = fl.pass ? 'var(--green)' : 'var(--red)';
const icon = fl.pass ? '&#10003;' : '&#10007;';
gHtml += '<span style="padding:3px 8px;border-radius:4px;background:'+bg+';color:'+cl+';font-size:11px;font-weight:500">'+icon+' '+c.label+'</span>';
});
gHtml += '</div>';
const allPass = passN === checks.length;
const barCls = allPass ? 'cb rdy' : 'cb no';
gHtml += '<div class="'+barCls+'" style="margin-bottom:0;font-size:13px;padding:8px">'+(allPass ? '&#x2705; '+title+'条件全部通过' : '&#x274C; '+title+'条件 '+passN+'/'+checks.length+' 通过')+'</div>';
gHtml += '</div>';
return {html: gHtml, passN: passN, total: checks.length};
}
let html = '';
html += '<div style="display:flex;align-items:center;justify-content:space-between;padding:8px 10px;background:var(--surface);border-radius:8px;border:0.5px solid var(--border);margin-bottom:6px;">';
html += '<div style="font-size:12px;color:var(--text)">当前 <strong style="color:var(--blue)">$'+Number(st.current_price).toFixed(0)+'</strong>';
if (zonePct !== null) html += ' <span style="color:var(--text3);margin:0 4px">&#183;</span> '+zoneLabel+''+Number(zonePct).toFixed(0)+'%';
html += '</div>';
html += '<div style="display:flex;gap:4px;">';
if (distToRes !== null) html += '<span style="padding:2px 8px;border-radius:4px;font-size:11px;background:rgba(248,81,73,0.12);color:var(--red);font-weight:500">距阻力 $'+Number(distToRes).toFixed(0)+'</span>';
if (distToSup !== null) html += '<span style="padding:2px 8px;border-radius:4px;font-size:11px;background:rgba(63,185,80,0.08);color:var(--green)">距支撑 $'+Number(distToSup).toFixed(0)+'</span>';
html += '</div></div>';
// 做空条件组
const shortResult = renderCheckGroup(shortChecks, '&#x2193; 做空条件(需全部通过)', 'var(--red)');
html += shortResult.html;
// 做多条件组
const longResult = renderCheckGroup(longChecks, '&#x2191; 做多条件(需全部通过)', 'var(--green)');
html += longResult.html;
// D1 趋势状态提示
const d1Up = f['trend_up_1d'] ? f['trend_up_1d'].pass : false;
const d1Down = f['trend_down_1d'] ? f['trend_down_1d'].pass : false;
if (!d1Up && !d1Down) {
html += '<div style="padding:8px;margin-top:6px;background:rgba(210,153,34,0.1);border:0.5px solid var(--yellow);border-radius:6px;font-size:12px;color:var(--yellow);text-align:center">&#x26A0; D1 趋势处于<strong>震荡</strong> — 做多做空总闸门均关闭,策略等待方向明确</div>';
}
el.innerHTML = html;
// Also update diagnosis tab
const bar = document.getElementById('conclBar');
if (bar) {
const shortOk = shortResult.passN === shortResult.total;
const longOk = longResult.passN === longResult.total;
if (shortOk) { bar.className = 'cb rdy'; bar.textContent = '做空条件全部通过'; }
else if (longOk) { bar.className = 'cb rdy'; bar.textContent = '做多条件全部通过'; }
else { bar.className = 'cb no'; bar.textContent = '无方向满足 — 做空 '+shortResult.passN+'/5做多 '+longResult.passN+'/5'; }
}
const fl = document.getElementById('fList');
if (fl) {
let diagHtml = '';
diagHtml += '<div class="sec-title">&#x2191; 做多过滤</div>';
longChecks.forEach(c => {
const fl2 = f[c.key];
if (!fl2) return;
diagHtml += '<div class="fi"><div><div class="nm">'+c.label+'</div><div class="ds">'+fl2.desc+'</div></div><div class="st '+(fl2.pass?'st-p':'st-f')+'">'+(fl2.pass?'通过':'拒绝')+'</div></div>';
});
diagHtml += '<div class="sec-title" style="margin-top:8px">&#x2193; 做空过滤</div>';
shortChecks.forEach(c => {
const fl2 = f[c.key];
if (!fl2) return;
diagHtml += '<div class="fi"><div><div class="nm">'+c.label+'</div><div class="ds">'+fl2.desc+'</div></div><div class="st '+(fl2.pass?'st-p':'st-f')+'">'+(fl2.pass?'通过':'拒绝')+'</div></div>';
});
fl.innerHTML = diagHtml;
}
}
let tradeInterval = null;
function swTab(n) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tc').forEach(t => t.classList.remove('active'));
const tabEl = document.querySelector(`.tab[onclick="swTab('${n}')"]`);
if (tabEl) tabEl.classList.add('active');
const contentEl = document.getElementById('tab-' + (n==='st'?'st':n==='diag'?'diag':'trade'));
if (contentEl) contentEl.classList.add('active');
if (n === 'trade') {
loadTradeData();
if (tradeInterval) clearInterval(tradeInterval);
tradeInterval = setInterval(loadTradeData, 30000);
} else {
if (tradeInterval) { clearInterval(tradeInterval); tradeInterval = null; }
}
}
function swSym(s) {
curSym = s;
document.querySelectorAll('.sb').forEach(b => b.classList.remove('ac'));
document.querySelector('.sb[onclick="swSym(\''+s+'\')"]').classList.add('ac');
if (cache[s]) render(); else loadData();
}
function refresh() { loadData(); }
// ============================================================
// 交易监测Tab 3
// ============================================================
let tradeCache = null;
async function loadTradeData() {
const headers = { 'X-API-Key': API_KEY, 'Accept': 'application/json' };
try {
const [accRes, tradeRes, statsRes] = await Promise.all([
fetch(SERVER_URL + '/api/account', { headers }),
fetch(SERVER_URL + '/api/trades?limit=50', { headers }),
fetch(SERVER_URL + '/api/trades/stats', { headers }),
]);
if (!accRes.ok || !tradeRes.ok || !statsRes.ok) throw new Error('API error');
tradeCache = {
account: await accRes.json(),
trades: await tradeRes.json(),
stats: await statsRes.json(),
};
renderTrade();
} catch(e) {
document.getElementById('accGrid').innerHTML = '<div class="er" style="grid-column:1/-1">数据获取失败: ' + e.message + '</div>';
}
}
function renderTrade() {
if (!tradeCache) return;
const a = tradeCache.account.account;
const s = tradeCache.account.stats;
const open = tradeCache.account.open_positions || [];
const trades = tradeCache.trades.trades || [];
const daily = tradeCache.account.daily_pnl || [];
const dist = tradeCache.stats.profit_distribution || [];
document.getElementById('accBalance').textContent = '$' + (a.current_balance || 0).toFixed(2);
const pnlEl = document.getElementById('accPnl');
const pnl = a.total_pnl_pct || 0;
pnlEl.textContent = (pnl >= 0 ? '+' : '') + pnl.toFixed(2) + '%';
pnlEl.className = 'v ' + (pnl > 0 ? 'gn' : pnl < 0 ? 'rd' : '');
document.getElementById('accWinRate').textContent = (s.win_rate || 0) + '%';
document.getElementById('accTrades').textContent = s.total_trades || 0;
document.getElementById('accStrategy').textContent = '策略: ' + (a.strategy || '--');
document.getElementById('accUpdate').textContent = '更新: ' + new Date().toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit'});
let posHtml = '';
if (open.length === 0) {
posHtml = '<div class="empty-state">无持仓</div>';
} else {
open.forEach(p => {
const dir = p.is_short ? 'SHORT' : 'LONG';
const dirCls = p.is_short ? 'pos-short' : 'pos-long';
const pct = p.unrealized_pnl_pct ? (p.unrealized_pnl_pct * 100).toFixed(2) + '%' : '--';
const pctCls = p.unrealized_pnl_pct > 0 ? 'win' : p.unrealized_pnl_pct < 0 ? 'lose' : '';
posHtml += '<div class="tr">'
+ '<div class="tp">'
+ '<span><span class="pos-badge ' + dirCls + '">' + dir + '</span> ' + p.pair + '</span>'
+ '<span class="pnl ' + pctCls + '">' + pct + '</span>'
+ '</div>'
+ '<div class="td">'
+ '<span>入场: $' + (p.open_rate || 0).toFixed(2) + '</span>'
+ '<span>数量: ' + (p.amount || 0).toFixed(4) + '</span>'
+ '<span>止损: $' + (p.stop_loss || 0).toFixed(2) + '</span>'
+ '</div>'
+ '</div>';
});
}
document.getElementById('openPos').innerHTML = posHtml;
document.getElementById('tradeTotal').textContent = tradeCache.trades.pagination.total;
let tbody = '';
if (trades.length === 0) {
tbody = '<tr><td colspan="6" class="empty-state">暂无交易记录(策略刚开始运行或尚未开仓)</td></tr>';
} else {
trades.forEach(t => {
const dir = t.is_short ? '空' : '多';
const dirCls = t.is_short ? 'lose' : 'win';
const pnlPct = t.close_profit ? (t.close_profit * 100).toFixed(2) + '%' : '--';
const pnlCls = (t.close_profit || 0) > 0 ? 'win' : (t.close_profit || 0) < 0 ? 'lose' : '';
const openTime = t.open_date ? t.open_date.slice(5,16).replace('T',' ') : '--';
const exit = t.exit_reason || (t.is_open ? '持仓中' : '--');
tbody += '<tr>'
+ '<td>' + openTime + '</td>'
+ '<td class="' + dirCls + '">' + dir + '</td>'
+ '<td class="num">$' + (t.open_rate || 0).toFixed(2) + '</td>'
+ '<td class="num">' + (t.close_rate ? '$' + t.close_rate.toFixed(2) : '持仓中') + '</td>'
+ '<td class="num ' + pnlCls + '">' + pnlPct + '</td>'
+ '<td>' + exit + '</td>'
+ '</tr>';
});
}
document.getElementById('tradeBody').innerHTML = tbody;
renderDailyPnl(daily);
renderDistChart(dist);
}
function renderDailyPnl(daily) {
const svg = document.getElementById('pnlSvg');
const w = 340, h = 120, pad = 20;
if (!daily || daily.length < 2) {
svg.innerHTML = '<rect width="'+w+'" height="'+h+'" fill="var(--surface)" rx="6"/><text x="170" y="60" text-anchor="middle" fill="var(--text3)" font-size="12">暂无数据</text>';
return;
}
const rev = daily.slice().reverse();
const vals = rev.map(d => d.pnl_abs || 0);
const mn = Math.min(0, ...vals), mx = Math.max(0, ...vals);
const rg = (mx - mn) || 1;
const cw = w - pad*2, ch = h - pad*2 - 10;
const bw = rev.length > 1 ? Math.min(cw / rev.length, 20) : 10;
let bars = '';
const zero = pad + ch - ((0 - mn) / rg) * ch;
rev.forEach((d, i) => {
const x = pad + i * (cw / Math.max(rev.length - 1, 1));
const v = d.pnl_abs || 0;
const barH = Math.abs(v) / rg * ch;
const y = v >= 0 ? zero - barH : zero;
const color = v >= 0 ? 'var(--green)' : 'var(--red)';
bars += '<rect x="' + (x - bw/2) + '" y="' + y + '" width="' + bw + '" height="' + barH + '" fill="' + color + '" opacity="0.8" rx="1"/>';
});
svg.innerHTML = '<rect width="'+w+'" height="'+h+'" fill="var(--surface)" rx="6"/>'
+ '<line x1="'+pad+'" y1="'+zero+'" x2="'+(w-pad)+'" y2="'+zero+'" stroke="var(--border)" stroke-width="0.5"/>'
+ bars
+ '<text x="'+pad+'" y="'+(h-4)+'" fill="var(--text3)" font-size="9">' + rev[0].day + '</text>'
+ '<text x="'+(w-pad)+'" y="'+(h-4)+'" text-anchor="end" fill="var(--text3)" font-size="9">' + rev[rev.length-1].day + '</text>';
}
function renderDistChart(dist) {
const svg = document.getElementById('distSvg');
const w = 340, h = 100, pad = 30;
if (!dist || dist.length === 0) {
svg.innerHTML = '<rect width="'+w+'" height="'+h+'" fill="var(--surface)" rx="6"/><text x="170" y="50" text-anchor="middle" fill="var(--text3)" font-size="12">暂无数据</text>';
return;
}
const maxCount = Math.max(...dist.map(d => d.count || 0), 1);
const cw = w - pad*2, ch = h - pad - 16;
const bw = cw / dist.length - 4;
let bars = '';
dist.forEach((d, i) => {
const x = pad + i * (cw / dist.length) + 2;
const barH = ((d.count || 0) / maxCount) * ch;
const y = h - 16 - barH;
const isLoss = d.profit_range && (d.profit_range.startsWith('\u2264') || d.profit_range.startsWith('-'));
const color = isLoss ? 'var(--red)' : 'var(--green)';
bars += '<rect x="' + x + '" y="' + y + '" width="' + bw + '" height="' + barH + '" fill="' + color + '" opacity="0.7" rx="1"/>';
bars += '<text x="' + (x + bw/2) + '" y="' + (h - 2) + '" text-anchor="middle" fill="var(--text3)" font-size="7" transform="rotate(-45,'+(x+bw/2)+','+(h-2)+')">' + (d.profit_range || '') + '</text>';
});
svg.innerHTML = '<rect width="'+w+'" height="'+h+'" fill="var(--surface)" rx="6"/>' + bars;
}
document.addEventListener('DOMContentLoaded', () => {
if (IS_CLOUDSTUDIO) {
document.getElementById('csBanner').style.display = 'block';
}
loadData();
setInterval(loadData, 60000);
});
</script>
</body>
</html>