/** 复盘四周期:按「包含成交时刻」的 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; } function cropBars(bars, markers, tf) { if (!bars.length) return bars; var maxN = TF_MAX[tf] || 100; if (bars.length <= maxN) return 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); // 用 UTC+8 显示,与复盘页北京时间一致 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 drawCandleChart(canvas, bars, markers, tf) { if (!canvas || !bars || !bars.length) return; bars = cropBars(bars, markers || {}, tf); markers = markers || {}; var dpr = window.devicePixelRatio || 1; var cssW = canvas.clientWidth || 400; var cssH = canvas.clientHeight || 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(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, 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, 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) { meta.textContent = bars.length + " 根 · " + fmtBjt(bars[0].ts) + "–" + fmtBjt(bars[n - 1].ts) + " 北京时间"; } } 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 paintAll() { document.querySelectorAll("canvas.chart-canvas").forEach(function (el) { try { var bars = JSON.parse(el.dataset.bars || "[]"); var tf = el.dataset.tf || "M1"; drawCandleChart(el, bars, readMarkers(el), tf); } catch (e) { console.warn(e); } }); } paintAll(); window.addEventListener("resize", paintAll); })();