ca117f7dd6
Co-authored-by: Cursor <cursoragent@cursor.com>
358 lines
11 KiB
JavaScript
358 lines
11 KiB
JavaScript
/** 复盘四周期:进出场落在包含时刻的 K 线;点击可放大 */
|
||
(function () {
|
||
var TF_MAX = { M1: 500, M5: 200, M15: 100, H1: 50 };
|
||
// 放大只把画布变大,根数与缩略图同一窗口(不再额外收紧)
|
||
var TF_SEC = { M1: 60, M5: 300, M15: 900, H1: 3600 };
|
||
|
||
function num(v) {
|
||
if (v == null || v === "") return null;
|
||
var n = Number(v);
|
||
return Number.isFinite(n) ? n : null;
|
||
}
|
||
|
||
/** 找到覆盖 ts 的那根 K 线:barOpen <= ts < barOpen+period */
|
||
function barIndexAt(bars, ts, tf) {
|
||
if (ts == null || !bars.length) return -1;
|
||
var period = TF_SEC[tf] || 60;
|
||
var t = Number(ts);
|
||
var i;
|
||
for (i = 0; i < bars.length; i++) {
|
||
var open = Number(bars[i].ts);
|
||
var end = open + period;
|
||
var last = i === bars.length - 1;
|
||
if (t >= open && (t < end || (last && t <= end))) {
|
||
return i;
|
||
}
|
||
}
|
||
if (t < Number(bars[0].ts)) return 0;
|
||
return bars.length - 1;
|
||
}
|
||
|
||
/** 始终以成交为中心裁剪到 maxN,避免短单淹没在全天 K 线里 */
|
||
function cropBars(bars, markers, tf, maxN) {
|
||
if (!bars.length) return bars;
|
||
maxN = maxN || TF_MAX[tf] || 100;
|
||
markers = markers || {};
|
||
|
||
if (markers.openTs == null) {
|
||
return bars.length > maxN ? bars.slice(-maxN) : bars;
|
||
}
|
||
|
||
var a = barIndexAt(bars, markers.openTs, tf);
|
||
var b =
|
||
markers.closeTs != null ? barIndexAt(bars, markers.closeTs, tf) : a;
|
||
if (a < 0) a = Math.floor(bars.length / 2);
|
||
if (b < 0) b = a;
|
||
var lo = Math.min(a, b);
|
||
var hi = Math.max(a, b);
|
||
var span = hi - lo + 1;
|
||
var pad = Math.max(4, Math.floor((maxN - span) / 2));
|
||
var start = Math.max(0, lo - pad);
|
||
var end = Math.min(bars.length, start + maxN);
|
||
start = Math.max(0, end - maxN);
|
||
return bars.slice(start, end);
|
||
}
|
||
|
||
function fmtBjt(ts) {
|
||
if (ts == null) return "";
|
||
var d = new Date(Number(ts) * 1000);
|
||
var utc = d.getTime() + d.getTimezoneOffset() * 60000;
|
||
var bjt = new Date(utc + 8 * 3600000);
|
||
var hh = String(bjt.getHours()).padStart(2, "0");
|
||
var mm = String(bjt.getMinutes()).padStart(2, "0");
|
||
return hh + ":" + mm;
|
||
}
|
||
|
||
function sideLabel(side) {
|
||
var s = (side || "").toLowerCase();
|
||
if (s === "buy") return "买入(多)";
|
||
if (s === "sell") return "卖出(空)";
|
||
return side || "";
|
||
}
|
||
|
||
function drawCandleChart(canvas, bars, markers, tf, opts) {
|
||
if (!canvas || !bars || !bars.length) return;
|
||
opts = opts || {};
|
||
var maxN = opts.maxN || TF_MAX[tf] || 100;
|
||
bars = cropBars(bars, markers || {}, tf, maxN);
|
||
markers = markers || {};
|
||
|
||
var dpr = window.devicePixelRatio || 1;
|
||
var cssW = canvas.clientWidth || opts.width || 400;
|
||
var cssH = canvas.clientHeight || opts.height || 220;
|
||
canvas.width = cssW * dpr;
|
||
canvas.height = cssH * dpr;
|
||
var ctx = canvas.getContext("2d");
|
||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||
|
||
var padL = 4;
|
||
var padR = 4;
|
||
var padT = 4;
|
||
var padB = 16;
|
||
|
||
var min = Infinity;
|
||
var max = -Infinity;
|
||
bars.forEach(function (b) {
|
||
min = Math.min(min, b.low);
|
||
max = Math.max(max, b.high);
|
||
});
|
||
if (markers.openPrice != null) {
|
||
min = Math.min(min, markers.openPrice);
|
||
max = Math.max(max, markers.openPrice);
|
||
}
|
||
if (markers.closePrice != null) {
|
||
min = Math.min(min, markers.closePrice);
|
||
max = Math.max(max, markers.closePrice);
|
||
}
|
||
if (!(max > min)) max = min + 1;
|
||
var padPrice = (max - min) * 0.06 || 0.5;
|
||
min -= padPrice;
|
||
max += padPrice;
|
||
|
||
var w = cssW - padL - padR;
|
||
var h = cssH - padT - padB;
|
||
var n = bars.length;
|
||
var slot = w / n;
|
||
|
||
ctx.clearRect(0, 0, cssW, cssH);
|
||
ctx.fillStyle = "#0e0e18";
|
||
ctx.fillRect(0, 0, cssW, cssH);
|
||
|
||
var yOf = function (p) {
|
||
return padT + ((max - p) / (max - min)) * h;
|
||
};
|
||
var xOf = function (i) {
|
||
return padL + i * slot + slot * 0.5;
|
||
};
|
||
|
||
var bodyW = Math.max(1, Math.min(opts.zoom ? 10 : 6, slot * 0.6));
|
||
bars.forEach(function (b, i) {
|
||
var x = xOf(i);
|
||
var up = b.close >= b.open;
|
||
ctx.strokeStyle = up ? "#0ecb81" : "#f6465d";
|
||
ctx.fillStyle = ctx.strokeStyle;
|
||
ctx.lineWidth = 1;
|
||
ctx.beginPath();
|
||
ctx.moveTo(x, yOf(b.high));
|
||
ctx.lineTo(x, yOf(b.low));
|
||
ctx.stroke();
|
||
var y1 = yOf(b.open);
|
||
var y2 = yOf(b.close);
|
||
var top = Math.min(y1, y2);
|
||
var bh = Math.max(1, Math.abs(y2 - y1));
|
||
ctx.fillRect(x - bodyW / 2, top, bodyW, bh);
|
||
});
|
||
|
||
ctx.fillStyle = "#888899";
|
||
ctx.font = "10px Microsoft YaHei UI, sans-serif";
|
||
ctx.textBaseline = "top";
|
||
ctx.textAlign = "left";
|
||
ctx.fillText(fmtBjt(bars[0].ts), padL, cssH - padB + 2);
|
||
ctx.textAlign = "right";
|
||
ctx.fillText(fmtBjt(bars[n - 1].ts), cssW - padR, cssH - padB + 2);
|
||
|
||
var entryColor = "#4cc2ff";
|
||
var exitColor = "#f0b429";
|
||
var entryIdx = barIndexAt(bars, markers.openTs, tf);
|
||
var exitIdx =
|
||
markers.closeTs != null ? barIndexAt(bars, markers.closeTs, tf) : -1;
|
||
var hasExit =
|
||
markers.closeTs != null &&
|
||
markers.closePrice != null &&
|
||
exitIdx >= 0;
|
||
|
||
function drawDot(i, price, color) {
|
||
if (i < 0 || price == null) return { x: 0, y: 0 };
|
||
var x = xOf(i);
|
||
var y = yOf(price);
|
||
ctx.beginPath();
|
||
ctx.arc(x, y, opts.zoom ? 6 : 4.5, 0, Math.PI * 2);
|
||
ctx.fillStyle = color;
|
||
ctx.fill();
|
||
ctx.lineWidth = 1.5;
|
||
ctx.strokeStyle = "#ffffff";
|
||
ctx.stroke();
|
||
return { x: x, y: y };
|
||
}
|
||
|
||
var pe = null;
|
||
var px = null;
|
||
if (entryIdx >= 0 && markers.openPrice != null) {
|
||
pe = drawDot(entryIdx, markers.openPrice, entryColor);
|
||
}
|
||
if (hasExit) {
|
||
if (Math.abs(exitIdx - entryIdx) <= 1) {
|
||
var x = xOf(exitIdx) + Math.max(6, slot * 0.8);
|
||
var y = yOf(markers.closePrice);
|
||
if (pe && Math.abs(y - pe.y) < 6) {
|
||
y = pe.y - 8;
|
||
}
|
||
ctx.beginPath();
|
||
ctx.arc(x, y, opts.zoom ? 6 : 4.5, 0, Math.PI * 2);
|
||
ctx.fillStyle = exitColor;
|
||
ctx.fill();
|
||
ctx.lineWidth = 1.5;
|
||
ctx.strokeStyle = "#ffffff";
|
||
ctx.stroke();
|
||
px = { x: x, y: y };
|
||
} else {
|
||
px = drawDot(exitIdx, markers.closePrice, exitColor);
|
||
}
|
||
}
|
||
|
||
ctx.font = "bold 10px Microsoft YaHei UI, sans-serif";
|
||
if (pe) {
|
||
ctx.fillStyle = entryColor;
|
||
ctx.textAlign = "center";
|
||
ctx.textBaseline = "bottom";
|
||
ctx.fillText("入", pe.x, pe.y - 6);
|
||
}
|
||
if (px) {
|
||
ctx.fillStyle = exitColor;
|
||
ctx.textAlign = "center";
|
||
ctx.textBaseline = "bottom";
|
||
ctx.fillText("出", px.x, px.y - 6);
|
||
}
|
||
|
||
var meta =
|
||
canvas.parentElement && canvas.parentElement.querySelector(".chart-meta");
|
||
if (meta) {
|
||
var sideBit = markers.side ? " · " + sideLabel(markers.side) : "";
|
||
meta.textContent =
|
||
bars.length +
|
||
" 根 · " +
|
||
fmtBjt(bars[0].ts) +
|
||
"–" +
|
||
fmtBjt(bars[n - 1].ts) +
|
||
" 北京时间" +
|
||
sideBit;
|
||
}
|
||
}
|
||
|
||
function readMarkers(el) {
|
||
return {
|
||
openTs: num(el.dataset.openTs),
|
||
closeTs: num(el.dataset.closeTs),
|
||
openPrice: num(el.dataset.openPrice),
|
||
closePrice: num(el.dataset.closePrice),
|
||
side: el.dataset.side || "",
|
||
};
|
||
}
|
||
|
||
function paintCanvas(el, opts) {
|
||
var bars = JSON.parse(el.dataset.bars || "[]");
|
||
var tf = el.dataset.tf || "M1";
|
||
drawCandleChart(el, bars, readMarkers(el), tf, opts);
|
||
}
|
||
|
||
function paintAll() {
|
||
document.querySelectorAll("canvas.chart-canvas").forEach(function (el) {
|
||
try {
|
||
paintCanvas(el, {});
|
||
} catch (e) {
|
||
console.warn(e);
|
||
}
|
||
});
|
||
}
|
||
|
||
function ensureLightbox() {
|
||
var box = document.getElementById("chart-lightbox");
|
||
if (box) return box;
|
||
box = document.createElement("div");
|
||
box.id = "chart-lightbox";
|
||
box.className = "chart-lightbox";
|
||
box.hidden = true;
|
||
box.innerHTML =
|
||
'<div class="chart-lightbox-panel">' +
|
||
'<div class="chart-lightbox-head">' +
|
||
'<span class="chart-lightbox-title"></span>' +
|
||
'<button type="button" class="chart-lightbox-close" aria-label="关闭">×</button>' +
|
||
"</div>" +
|
||
'<p class="muted chart-lightbox-hint">与缩略图同窗口放大 · Esc / 点击遮罩关闭</p>' +
|
||
'<canvas class="chart-lightbox-canvas"></canvas>' +
|
||
"</div>";
|
||
document.body.appendChild(box);
|
||
|
||
box.addEventListener("click", function (e) {
|
||
if (e.target === box) closeLightbox();
|
||
});
|
||
box
|
||
.querySelector(".chart-lightbox-close")
|
||
.addEventListener("click", closeLightbox);
|
||
document.addEventListener("keydown", function (e) {
|
||
if (e.key === "Escape" && !box.hidden) closeLightbox();
|
||
});
|
||
return box;
|
||
}
|
||
|
||
function closeLightbox() {
|
||
var box = document.getElementById("chart-lightbox");
|
||
if (box) box.hidden = true;
|
||
}
|
||
|
||
function openLightbox(srcCanvas) {
|
||
var box = ensureLightbox();
|
||
var tf = srcCanvas.dataset.tf || "M1";
|
||
var markers = readMarkers(srcCanvas);
|
||
var title = box.querySelector(".chart-lightbox-title");
|
||
title.textContent =
|
||
tf +
|
||
" · " +
|
||
(markers.side ? sideLabel(markers.side) + " · " : "") +
|
||
"点击放大";
|
||
|
||
var canvas = box.querySelector(".chart-lightbox-canvas");
|
||
canvas.dataset.tf = tf;
|
||
canvas.dataset.bars = srcCanvas.dataset.bars;
|
||
canvas.dataset.openTs = srcCanvas.dataset.openTs || "";
|
||
canvas.dataset.closeTs = srcCanvas.dataset.closeTs || "";
|
||
canvas.dataset.openPrice = srcCanvas.dataset.openPrice || "";
|
||
canvas.dataset.closePrice = srcCanvas.dataset.closePrice || "";
|
||
canvas.dataset.side = srcCanvas.dataset.side || "";
|
||
|
||
box.hidden = false;
|
||
// 等面板可见后再量宽高
|
||
requestAnimationFrame(function () {
|
||
try {
|
||
drawCandleChart(
|
||
canvas,
|
||
JSON.parse(canvas.dataset.bars || "[]"),
|
||
markers,
|
||
tf,
|
||
{ zoom: true, maxN: TF_MAX[tf] || 100 }
|
||
);
|
||
} catch (e) {
|
||
console.warn(e);
|
||
}
|
||
});
|
||
}
|
||
|
||
document.addEventListener("click", function (e) {
|
||
var canvas = e.target.closest && e.target.closest("canvas.chart-canvas");
|
||
if (!canvas) return;
|
||
openLightbox(canvas);
|
||
});
|
||
|
||
paintAll();
|
||
window.addEventListener("resize", function () {
|
||
paintAll();
|
||
var box = document.getElementById("chart-lightbox");
|
||
if (box && !box.hidden) {
|
||
var canvas = box.querySelector(".chart-lightbox-canvas");
|
||
if (canvas && canvas.dataset.bars) {
|
||
try {
|
||
drawCandleChart(
|
||
canvas,
|
||
JSON.parse(canvas.dataset.bars || "[]"),
|
||
readMarkers(canvas),
|
||
canvas.dataset.tf || "M1",
|
||
{ zoom: true, maxN: TF_MAX[canvas.dataset.tf] || 100 }
|
||
);
|
||
} catch (err) {
|
||
/* ignore */
|
||
}
|
||
}
|
||
}
|
||
});
|
||
})();
|