333 lines
12 KiB
HTML
333 lines
12 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<!-- Google tag (gtag.js) -->
|
|
<script async src="https://www.googletagmanager.com/gtag/js?id=G-LVVXH3TL04"></script>
|
|
<script>
|
|
window.dataLayer = window.dataLayer || [];
|
|
function gtag(){dataLayer.push(arguments);}
|
|
gtag('js', new Date());
|
|
gtag('config', 'G-LVVXH3TL04');
|
|
</script>
|
|
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1.0,user-scalable=no">
|
|
<title>LINK HMM Regime — K线</title>
|
|
<style>
|
|
*{margin:0;padding:0;box-sizing:border-box}
|
|
body{background:#0d1117;color:#c9d1d9;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;overflow:hidden;touch-action:none}
|
|
#topbar{background:#161b22;padding:10px 16px;display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid #30363d;flex-wrap:wrap;gap:6px}
|
|
#topbar h1{font-size:14px;font-weight:600;color:#f0f6fc}
|
|
.legend{display:flex;gap:10px;font-size:11px;flex-wrap:wrap}
|
|
.legend span{display:flex;align-items:center;gap:4px}
|
|
.legend i{display:inline-block;width:10px;height:10px;border-radius:2px}
|
|
#chart{width:100%;height:calc(100vh - 44px);display:block;cursor:crosshair}
|
|
#loading{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);color:#8b949e;font-size:14px;z-index:10}
|
|
#tooltip{position:fixed;background:#21262d;color:#c9d1d9;padding:6px 10px;border-radius:6px;font-size:12px;border:1px solid #30363d;pointer-events:none;display:none;white-space:nowrap;z-index:20}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="topbar">
|
|
<h1>LINK/USD — HMM Regime (1m K线)</h1>
|
|
<div class="legend">
|
|
<span><i style="background:rgba(46,204,113,0.5);border:1px solid #2ecc71"></i> Low Spread</span>
|
|
<span><i style="background:rgba(241,196,15,0.4);border:1px solid #f1c40f"></i> Mid Spread</span>
|
|
<span><i style="background:rgba(231,76,60,0.4);border:1px solid #e74c3c"></i> High Spread</span>
|
|
</div>
|
|
</div>
|
|
<canvas id="chart"></canvas>
|
|
<div id="tooltip"></div>
|
|
<div id="loading">Loading data...</div>
|
|
<script>
|
|
(function(){
|
|
var canvas = document.getElementById('chart');
|
|
var ctx = canvas.getContext('2d');
|
|
var tt = document.getElementById('tooltip');
|
|
|
|
var RC = ['rgba(231,76,60,0.28)','rgba(241,196,15,0.18)','rgba(46,204,113,0.22)'];
|
|
var RL = ['#e74c3c','#f1c40f','#2ecc71'];
|
|
// Remap: HMM labels → spread-ordered. R0=lowest spread, R1=highest, R2=mid
|
|
// low→green(idx2), mid→yellow(idx1), high→red(idx0)
|
|
var RM = {0:2, 1:0, 2:1};
|
|
function rc(r) { return RC[RM[r]]; }
|
|
function rl(r) { return RL[RM[r]]; }
|
|
var GC = '#21262d', AC = '#8b949e', BG = '#0d1117';
|
|
var GREEN = '#26a69a', RED = '#ef5350';
|
|
var MA = {t:16,r:70,b:28,l:10};
|
|
|
|
var DATA = null;
|
|
var SIGNALS = null;
|
|
var viewStart = 0, viewEnd = 1;
|
|
var isPanning = false, panStart = 0;
|
|
var pinchDist0 = 0, viewMid0 = 0.5, viewRange0 = 1;
|
|
|
|
function init() {
|
|
canvas.width = window.innerWidth;
|
|
canvas.height = window.innerHeight - 44;
|
|
draw();
|
|
}
|
|
|
|
function draw() {
|
|
if (!DATA) return;
|
|
var W = canvas.width, H = canvas.height;
|
|
var pw = W - MA.l - MA.r, ph = H - MA.t - MA.b;
|
|
var N = DATA.length;
|
|
var i0 = Math.floor(viewStart * N);
|
|
var i1 = Math.ceil(viewEnd * N) - 1;
|
|
i0 = Math.max(0, i0); i1 = Math.min(N - 1, i1);
|
|
|
|
ctx.clearRect(0, 0, W, H);
|
|
ctx.fillStyle = BG;
|
|
ctx.fillRect(0, 0, W, H);
|
|
|
|
// Price range
|
|
var pMin = Infinity, pMax = -Infinity;
|
|
for (var i = i0; i <= i1; i++) {
|
|
var d = DATA[i];
|
|
if (d.l < pMin) pMin = d.l;
|
|
if (d.h > pMax) pMax = d.h;
|
|
}
|
|
var pR = pMax - pMin || 0.001;
|
|
pMin -= pR * 0.05; pMax += pR * 0.05; pR = pMax - pMin;
|
|
|
|
function tX(i) { return MA.l + ((i - i0) / (i1 - i0 || 1)) * pw; }
|
|
function tY(v) { return MA.t + (1 - (v - pMin) / pR) * ph; }
|
|
|
|
// Regime background bands
|
|
var prevR = -1, bStart = i0;
|
|
for (var i = i0; i <= i1; i++) {
|
|
var r = DATA[i].r;
|
|
if (r !== prevR) {
|
|
if (prevR >= 0) {
|
|
ctx.fillStyle = rc(prevR);
|
|
ctx.fillRect(tX(bStart) - 1, MA.t, tX(i) - tX(bStart) + 2, ph);
|
|
}
|
|
bStart = i; prevR = r;
|
|
}
|
|
}
|
|
if (prevR >= 0) {
|
|
ctx.fillStyle = rc(prevR);
|
|
ctx.fillRect(tX(bStart) - 1, MA.t, tX(i1) - tX(bStart) + 2, ph);
|
|
}
|
|
|
|
// Grid + Y labels
|
|
var nY = 8;
|
|
ctx.strokeStyle = GC; ctx.lineWidth = 0.5;
|
|
for (var gy = 0; gy <= nY; gy++) {
|
|
var y = MA.t + (gy / nY) * ph;
|
|
var price = pMax - (gy / nY) * pR;
|
|
ctx.beginPath(); ctx.moveTo(MA.l, y); ctx.lineTo(MA.l + pw, y); ctx.stroke();
|
|
ctx.fillStyle = AC; ctx.font = '11px -apple-system,BlinkMacSystemFont,sans-serif';
|
|
ctx.textAlign = 'right'; ctx.fillText(price.toFixed(4), W - 5, y + 4);
|
|
}
|
|
|
|
// X labels
|
|
var nX = 6;
|
|
ctx.textAlign = 'center'; ctx.fillStyle = AC;
|
|
ctx.font = '10px -apple-system,BlinkMacSystemFont,sans-serif';
|
|
for (var gx = 0; gx <= nX; gx++) {
|
|
var idx = i0 + Math.floor((gx / nX) * (i1 - i0 + 1));
|
|
if (idx > i1) idx = i1;
|
|
var x = tX(idx);
|
|
var d = new Date(DATA[idx].t * 1000);
|
|
ctx.fillText(d.toISOString().substring(11, 19), x, H - MA.b + 16);
|
|
}
|
|
|
|
// ─── K-line candlesticks ───
|
|
var candleW = Math.max(1, (pw / (i1 - i0 + 1)) * 0.7);
|
|
var candleGap = (pw / (i1 - i0 + 1));
|
|
|
|
for (var i = i0; i <= i1; i++) {
|
|
var k = DATA[i];
|
|
var cx = tX(i);
|
|
var openY = tY(k.o), closeY = tY(k.c);
|
|
var highY = tY(k.h), lowY = tY(k.l);
|
|
var isGreen = k.c >= k.o;
|
|
|
|
// Wick
|
|
ctx.strokeStyle = isGreen ? GREEN : RED;
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
ctx.moveTo(cx, highY);
|
|
ctx.lineTo(cx, lowY);
|
|
ctx.stroke();
|
|
|
|
// Body
|
|
var bodyTop = isGreen ? closeY : openY;
|
|
var bodyH = Math.max(1, Math.abs(closeY - openY));
|
|
ctx.fillStyle = isGreen ? GREEN : RED;
|
|
ctx.fillRect(cx - candleW / 2, bodyTop, candleW, bodyH);
|
|
}
|
|
|
|
// Transition markers (dashed vertical lines)
|
|
prevR = DATA[Math.max(0, i0 - 1)].r;
|
|
for (var i = i0; i <= i1; i++) {
|
|
if (DATA[i].r !== prevR) {
|
|
var mx = tX(i);
|
|
ctx.strokeStyle = rl(DATA[i].r); ctx.lineWidth = 1;
|
|
ctx.setLineDash([2, 3]);
|
|
ctx.beginPath(); ctx.moveTo(mx, MA.t); ctx.lineTo(mx, MA.t + ph); ctx.stroke();
|
|
ctx.setLineDash([]);
|
|
prevR = DATA[i].r;
|
|
}
|
|
}
|
|
|
|
// ─── Signal markers (spread surge > 95% + >2x + 3min cooldown) ───
|
|
if (SIGNALS) {
|
|
for (var si = 0; si < SIGNALS.length; si++) {
|
|
var sig = SIGNALS[si];
|
|
if (sig.idx < i0 || sig.idx > i1) continue;
|
|
var candleIdx = -1;
|
|
for (var ci = i0; ci <= i1; ci++) {
|
|
if (DATA[ci].t <= sig.ts && (ci === i1 || DATA[ci+1].t > sig.ts)) {
|
|
candleIdx = ci; break;
|
|
}
|
|
}
|
|
if (candleIdx < 0) continue;
|
|
var sx = tX(candleIdx);
|
|
var k = DATA[candleIdx];
|
|
var sy = tY(k.l) + 8; // below the candle low
|
|
// Red triangle
|
|
ctx.fillStyle = '#ff6b6b';
|
|
ctx.beginPath();
|
|
ctx.moveTo(sx, sy);
|
|
ctx.lineTo(sx - 5, sy + 10);
|
|
ctx.lineTo(sx + 5, sy + 10);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
ctx.strokeStyle = '#ff4444'; ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
ctx.moveTo(sx, sy);
|
|
ctx.lineTo(sx - 5, sy + 10);
|
|
ctx.lineTo(sx + 5, sy + 10);
|
|
ctx.closePath();
|
|
ctx.stroke();
|
|
}
|
|
}
|
|
|
|
// Price labels at edges
|
|
ctx.fillStyle = AC; ctx.font = 'bold 11px -apple-system,BlinkMacSystemFont,sans-serif';
|
|
ctx.textAlign = 'right';
|
|
ctx.fillText(DATA[i0].c.toFixed(4), MA.l - 4, tY(DATA[i0].c) + 4);
|
|
ctx.textAlign = 'left';
|
|
ctx.fillText(DATA[i1].c.toFixed(4), MA.l + pw + 4, tY(DATA[i1].c) + 4);
|
|
|
|
// Zoom %
|
|
ctx.fillStyle = AC; ctx.font = '10px sans-serif'; ctx.textAlign = 'right';
|
|
ctx.fillText(Math.round((viewEnd - viewStart) * 100) + '%', W - 10, MA.t - 4);
|
|
}
|
|
|
|
// Zoom (scroll)
|
|
canvas.addEventListener('wheel', function(e) {
|
|
e.preventDefault();
|
|
if (!DATA) return;
|
|
var rect = canvas.getBoundingClientRect();
|
|
var mx = (e.clientX - rect.left - MA.l) / (canvas.width - MA.l - MA.r);
|
|
mx = Math.max(0, Math.min(1, mx));
|
|
var mid = viewStart + mx * (viewEnd - viewStart);
|
|
var range = viewEnd - viewStart;
|
|
range = Math.max(0.003, Math.min(1, range * (e.deltaY > 0 ? 1.3 : 0.7)));
|
|
viewStart = Math.max(0, mid - range * mx);
|
|
viewEnd = Math.min(1, viewStart + range);
|
|
viewStart = Math.max(0, viewEnd - range);
|
|
draw();
|
|
}, {passive: false});
|
|
|
|
// Pan (drag)
|
|
canvas.addEventListener('mousedown', function(e) { isPanning = true; panStart = e.clientX; });
|
|
window.addEventListener('mousemove', function(e) {
|
|
if (!DATA) return;
|
|
if (isPanning) {
|
|
var dx = e.clientX - panStart; panStart = e.clientX;
|
|
var pw = canvas.width - MA.l - MA.r;
|
|
var shift = -(dx / pw) * (viewEnd - viewStart);
|
|
viewStart = Math.max(0, Math.min(1 - (viewEnd - viewStart), viewStart + shift));
|
|
viewEnd = viewStart + (viewEnd - viewStart);
|
|
draw(); return;
|
|
}
|
|
// Tooltip
|
|
var rect = canvas.getBoundingClientRect();
|
|
var mx = e.clientX - rect.left;
|
|
if (mx < MA.l || mx > canvas.width - MA.r) { tt.style.display = 'none'; return; }
|
|
var pw = canvas.width - MA.l - MA.r;
|
|
var idx = Math.floor(viewStart * DATA.length + ((mx - MA.l) / pw) * (viewEnd - viewStart) * DATA.length);
|
|
idx = Math.max(0, Math.min(DATA.length - 1, idx));
|
|
var k = DATA[idx];
|
|
var d = new Date(k.t * 1000);
|
|
var chg = ((k.c - k.o) / k.o * 100).toFixed(2);
|
|
var sign = chg >= 0 ? '+' : '';
|
|
var sigHTML = '';
|
|
if (SIGNALS) {
|
|
for (var si = 0; si < SIGNALS.length; si++) {
|
|
var sg = SIGNALS[si];
|
|
if (Math.abs(sg.ts - k.t) <= 30) {
|
|
sigHTML = '<br><span style="color:#ff6b6b">⚠ SPREAD SURGE</span> ' +
|
|
sg.spread.toFixed(4) + ' (' + (sg.spread/sg.baseline).toFixed(1) + 'x baseline)';
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
tt.innerHTML = '<b>O</b> ' + k.o.toFixed(4) + ' <b>H</b> ' + k.h.toFixed(4) +
|
|
' <b>L</b> ' + k.l.toFixed(4) + ' <b>C</b> ' + k.c.toFixed(4) +
|
|
' <b style="color:' + (k.c >= k.o ? '#26a69a' : '#ef5350') + '">' + sign + chg + '%</b>' +
|
|
'<br><span style="color:#8b949e">' + d.toISOString().substring(11, 19) + ' R' + k.r + '</span>' + sigHTML;
|
|
tt.style.display = 'block';
|
|
tt.style.left = (e.clientX + 15) + 'px';
|
|
tt.style.top = (e.clientY - 50) + 'px';
|
|
});
|
|
window.addEventListener('mouseup', function() { isPanning = false; });
|
|
canvas.addEventListener('mouseleave', function() { isPanning = false; tt.style.display = 'none'; });
|
|
|
|
// Double-click reset
|
|
canvas.addEventListener('dblclick', function() { viewStart = 0; viewEnd = 1; draw(); });
|
|
|
|
// Touch
|
|
canvas.addEventListener('touchstart', function(e) {
|
|
if (e.touches.length === 1) { isPanning = true; panStart = e.touches[0].clientX; }
|
|
if (e.touches.length === 2) {
|
|
isPanning = false;
|
|
pinchDist0 = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY);
|
|
viewMid0 = (viewStart + viewEnd) / 2; viewRange0 = viewEnd - viewStart;
|
|
}
|
|
}, {passive: false});
|
|
canvas.addEventListener('touchmove', function(e) {
|
|
if (!DATA) return;
|
|
if (e.touches.length === 2) {
|
|
e.preventDefault();
|
|
var d = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY);
|
|
var s = pinchDist0 > 0 ? d / pinchDist0 : 1;
|
|
var nr = Math.max(0.003, Math.min(1, viewRange0 / s));
|
|
viewStart = Math.max(0, viewMid0 - nr / 2);
|
|
viewEnd = Math.min(1, viewStart + nr); viewStart = Math.max(0, viewEnd - nr);
|
|
draw(); return;
|
|
}
|
|
if (e.touches.length === 1 && isPanning) {
|
|
var dx = e.touches[0].clientX - panStart; panStart = e.touches[0].clientX;
|
|
var pw = canvas.width - MA.l - MA.r;
|
|
var shift = -(dx / pw) * (viewEnd - viewStart);
|
|
viewStart = Math.max(0, Math.min(1 - (viewEnd - viewStart), viewStart + shift));
|
|
viewEnd = viewStart + (viewEnd - viewStart);
|
|
draw(); return;
|
|
}
|
|
}, {passive: false});
|
|
canvas.addEventListener('touchend', function() { isPanning = false; tt.style.display = 'none'; });
|
|
|
|
window.addEventListener('resize', init);
|
|
|
|
Promise.all([
|
|
fetch('l2_LINK_kline_data.json').then(function(r){return r.json()}),
|
|
fetch('l2_LINK_signals.json').then(function(r){return r.json()})
|
|
]).then(function(results) {
|
|
DATA = results[0];
|
|
SIGNALS = results[1];
|
|
document.getElementById('loading').remove();
|
|
init();
|
|
})
|
|
.catch(function(e) {
|
|
document.getElementById('loading').textContent = 'Error: ' + e.message;
|
|
});
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|