diff --git a/app/static/css/app.css b/app/static/css/app.css index b740c64..10c7b1b 100644 --- a/app/static/css/app.css +++ b/app/static/css/app.css @@ -130,8 +130,26 @@ th { color: var(--muted); font-weight: 600; } padding: 10px; min-height: 240px; } -.chart-box h4 { margin: 0 0 8px; font-size: 13px; color: var(--muted); } -.chart-canvas { width: 100%; height: 240px; display: block; } +.chart-box h4 { + margin: 0 0 8px; + font-size: 13px; + color: var(--muted); + display: flex; + align-items: baseline; + gap: 8px; + flex-wrap: wrap; +} +.chart-zoom-hint { + margin-left: auto; + font-size: 11px; + font-weight: 400; +} +.chart-canvas { + width: 100%; + height: 240px; + display: block; + cursor: zoom-in; +} .empty-chart { height: 240px; display: flex; @@ -140,3 +158,52 @@ th { color: var(--muted); font-weight: 600; } color: var(--muted); font-size: 13px; } + +.chart-lightbox { + position: fixed; + inset: 0; + z-index: 1000; + background: rgba(0, 0, 0, 0.72); + display: flex; + align-items: center; + justify-content: center; + padding: 24px; +} +.chart-lightbox[hidden] { display: none !important; } +.chart-lightbox-panel { + width: min(1100px, 96vw); + background: #12121c; + border: 1px solid var(--border); + border-radius: 14px; + padding: 14px 16px 16px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.45); +} +.chart-lightbox-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 4px; +} +.chart-lightbox-title { + font-size: 15px; + font-weight: 700; +} +.chart-lightbox-close { + border: 0; + background: transparent; + color: var(--muted); + font-size: 28px; + line-height: 1; + cursor: pointer; + padding: 0 4px; +} +.chart-lightbox-close:hover { color: #fff; } +.chart-lightbox-hint { margin: 0 0 10px; font-size: 12px; } +.chart-lightbox-canvas { + width: 100%; + height: min(62vh, 520px); + display: block; + border-radius: 10px; + background: #0e0e18; +} diff --git a/app/static/js/charts.js b/app/static/js/charts.js index d92fb4d..f69b606 100644 --- a/app/static/js/charts.js +++ b/app/static/js/charts.js @@ -1,6 +1,7 @@ -/** 复盘四周期:按「包含成交时刻」的 K 线落点,避免偏到下一根 */ +/** 复盘四周期:进出场落在包含时刻的 K 线;点击可放大 */ (function () { var TF_MAX = { M1: 500, M5: 200, M15: 100, H1: 50 }; + var TF_ZOOM = { M1: 90, M5: 48, M15: 32, H1: 20 }; var TF_SEC = { M1: 60, M5: 300, M15: 900, H1: 3600 }; function num(v) { @@ -9,7 +10,7 @@ return Number.isFinite(n) ? n : null; } - /** 找到覆盖 ts 的那根 K 线:barOpen <= ts < barOpen+period(最后一根含右边界) */ + /** 找到覆盖 ts 的那根 K 线:barOpen <= ts < barOpen+period */ function barIndexAt(bars, ts, tf) { if (ts == null || !bars.length) return -1; var period = TF_SEC[tf] || 60; @@ -23,15 +24,19 @@ return i; } } - // 落在首根之前 / 末根之后:夹到边界 if (t < Number(bars[0].ts)) return 0; return bars.length - 1; } - function cropBars(bars, markers, tf) { + /** 始终以成交为中心裁剪到 maxN,避免短单淹没在全天 K 线里 */ + function cropBars(bars, markers, tf, maxN) { if (!bars.length) return bars; - var maxN = TF_MAX[tf] || 100; - if (bars.length <= maxN) 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 = @@ -51,7 +56,6 @@ 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"); @@ -59,14 +63,23 @@ return hh + ":" + mm; } - function drawCandleChart(canvas, bars, markers, tf) { + 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; - bars = cropBars(bars, markers || {}, tf); + 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 || 400; - var cssH = canvas.clientHeight || 220; + 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"); @@ -75,7 +88,7 @@ var padL = 4; var padR = 4; var padT = 4; - var padB = 16; // 底部时间轴 + var padB = 16; var min = Infinity; var max = -Infinity; @@ -112,7 +125,7 @@ return padL + i * slot + slot * 0.5; }; - var bodyW = Math.max(1, Math.min(6, slot * 0.6)); + 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; @@ -130,7 +143,6 @@ ctx.fillRect(x - bodyW / 2, top, bodyW, bh); }); - // 底部左右时间(北京时间),方便对照成交时刻 ctx.fillStyle = "#888899"; ctx.font = "10px Microsoft YaHei UI, sans-serif"; ctx.textBaseline = "top"; @@ -154,7 +166,7 @@ var x = xOf(i); var y = yOf(price); ctx.beginPath(); - ctx.arc(x, y, 4.5, 0, Math.PI * 2); + ctx.arc(x, y, opts.zoom ? 6 : 4.5, 0, Math.PI * 2); ctx.fillStyle = color; ctx.fill(); ctx.lineWidth = 1.5; @@ -176,7 +188,7 @@ y = pe.y - 8; } ctx.beginPath(); - ctx.arc(x, y, 4.5, 0, Math.PI * 2); + ctx.arc(x, y, opts.zoom ? 6 : 4.5, 0, Math.PI * 2); ctx.fillStyle = exitColor; ctx.fill(); ctx.lineWidth = 1.5; @@ -202,15 +214,18 @@ ctx.fillText("出", px.x, px.y - 6); } - var meta = canvas.parentElement && canvas.parentElement.querySelector(".chart-meta"); + 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; } } @@ -224,18 +239,119 @@ }; } + 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 { - var bars = JSON.parse(el.dataset.bars || "[]"); - var tf = el.dataset.tf || "M1"; - drawCandleChart(el, bars, readMarkers(el), tf); + 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 = + '
' + + '
' + + '' + + '' + + "
" + + '

围绕进出场收紧显示 · Esc / 点击遮罩关闭

' + + '' + + "
"; + 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_ZOOM[tf] || 60 } + ); + } 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", 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_ZOOM[canvas.dataset.tf] || 60 } + ); + } catch (err) { + /* ignore */ + } + } + } + }); })(); diff --git a/app/templates/record_detail.html b/app/templates/record_detail.html index 50f75be..faf0ea7 100644 --- a/app/templates/record_detail.html +++ b/app/templates/record_detail.html @@ -1,13 +1,19 @@ {% extends "base.html" %} {% block title %}复盘 #{{ trade.id }} · {{ app_name }}{% endblock %} {% block content %} +{% set side_cn = '买入(多)' if trade.side == 'buy' else ('卖出(空)' if trade.side == 'sell' else (trade.side or '—')) %} +{% set side_cls = 'green' if trade.side == 'buy' else ('red' if trade.side == 'sell' else 'gray') %}

← 返回列表

-

复盘 #{{ trade.id }} · {{ trade.symbol }} · {{ trade.side }}

+

+ 复盘 #{{ trade.id }} · {{ trade.symbol }} · + {{ side_cn }} +

成交信息

+ @@ -63,7 +69,8 @@

四周期 K 线(M1 / M5 / M15 / H1)

- 显示根数:M1 500 · M5 200 · M15 100 · H1 50。入/出落在包含成交时刻的那根 K 线(北京时间)。 + 方向 {{ side_cn }} · + 入/出落在包含成交时刻的 K 线 · 点击图可放大(放大时围绕进出场收紧)。 ● 入场 {{ trade.open_price|fmt_price }}{% if trade.open_time_fmt %} · {{ trade.open_time_fmt }}{% endif %} {% if trade.close_price is not none %} · ● 出场 {{ trade.close_price|fmt_price }}{% if trade.close_time_fmt %} · {{ trade.close_time_fmt }}{% endif %} @@ -72,9 +79,12 @@

{% for tf in ['M1','M5','M15','H1'] %}
-

{{ tf }}

+

{{ tf }} + {% if charts[tf] %}点击放大{% endif %} +

{% if charts[tf] %}
- +
方向{{ side_cn }}
账户{{ trade.account_name or '—' }}
机器{{ trade.machine_name or '—' }}
手数{{ trade.volume }}
{{ t.id }} {{ t.symbol }}{{ t.side }}{% if t.side=='buy' %}买入{% elif t.side=='sell' %}卖出{% else %}{{ t.side }}{% endif %} {{ t.volume }} {{ t.open_price|fmt_price }} {{ t.close_price|fmt_price }}