diff --git a/app/routes.py b/app/routes.py index a2d648a..a40ef2e 100644 --- a/app/routes.py +++ b/app/routes.py @@ -928,19 +928,27 @@ def register_routes(app: Flask) -> None: flash("记录不存在或无权查看", "error") return redirect(url_for("records_page")) _annotate_trade_times(trade) - # 以开仓~平仓为中心取窗口,保证入场/出场都在图内 + # 各周期不同前后窗口,避免 M1 过密、H1 过稀 open_ts = float( trade.get("open_time") or trade.get("created_at") or database.now() ) close_raw = trade.get("close_time") close_ts = float(close_raw) if close_raw not in (None, "") else open_ts - pad = 3600 * 3 - start_ts = min(open_ts, close_ts) - pad - end_ts = max(open_ts, close_ts) + pad + tf_pad = { + "M1": 90 * 60, # ±90 分钟 + "M5": 4 * 3600, # ±4 小时 + "M15": 8 * 3600, # ±8 小时 + "H1": 24 * 3600, # ±1 天 + } charts = {} for tf in ("M1", "M5", "M15", "H1"): + pad = tf_pad[tf] charts[tf] = database.get_bars( - conn, trade["symbol"], tf, start_ts, end_ts + conn, + trade["symbol"], + tf, + min(open_ts, close_ts) - pad, + max(open_ts, close_ts) + pad, ) return render_template( "record_detail.html", diff --git a/app/static/css/app.css b/app/static/css/app.css index e38c6ed..2ce0321 100644 --- a/app/static/css/app.css +++ b/app/static/css/app.css @@ -128,12 +128,12 @@ th { color: var(--muted); font-weight: 600; } border: 1px solid var(--border); border-radius: 12px; padding: 10px; - min-height: 220px; + min-height: 240px; } .chart-box h4 { margin: 0 0 8px; font-size: 13px; color: var(--muted); } -.chart-canvas { width: 100%; height: 180px; display: block; } +.chart-canvas { width: 100%; height: 200px; display: block; } .empty-chart { - height: 180px; + height: 200px; display: flex; align-items: center; justify-content: center; diff --git a/app/static/js/charts.js b/app/static/js/charts.js index fe07ff8..16b7ecf 100644 --- a/app/static/js/charts.js +++ b/app/static/js/charts.js @@ -1,5 +1,7 @@ -/** 轻量蜡烛图(无外部依赖):复盘四周期 + 入场/出场标注 */ +/** 复盘四周期蜡烛图:围绕成交裁剪窗口,入场/出场轻量标注 */ (function () { + var TF_MAX = { M1: 90, M5: 48, M15: 36, H1: 24 }; + function num(v) { if (v == null || v === "") return null; var n = Number(v); @@ -20,27 +22,50 @@ return best; } - function drawCandleChart(canvas, bars, markers) { + /** 只保留成交前后一段,避免 M1 挤满、H1 太空 */ + function cropBars(bars, markers, tf) { + if (!bars.length) return bars; + var maxN = TF_MAX[tf] || 60; + if (bars.length <= maxN) return bars; + + var a = nearestBarIndex(bars, markers.openTs); + var b = markers.closeTs != null ? nearestBarIndex(bars, markers.closeTs) : 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(8, 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 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 || 180; + var cssH = canvas.clientHeight || 200; canvas.width = cssW * dpr; canvas.height = cssH * dpr; var ctx = canvas.getContext("2d"); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); - var padL = 8; - var padR = 8; - var padT = 18; - var padB = 8; + var padL = 6; + var padR = 56; // 右侧留给价签 + var padT = 10; + var padB = 6; + var min = Infinity; var max = -Infinity; bars.forEach(function (b) { min = Math.min(min, b.low); max = Math.max(max, b.high); }); - markers = markers || {}; if (markers.openPrice != null) { min = Math.min(min, markers.openPrice); max = Math.max(max, markers.openPrice); @@ -49,11 +74,8 @@ min = Math.min(min, markers.closePrice); max = Math.max(max, markers.closePrice); } - if (!(max > min)) { - max = min + 1; - } - // 上下留一点边,避免标注贴边 - var padPrice = (max - min) * 0.04 || 0.5; + if (!(max > min)) max = min + 1; + var padPrice = (max - min) * 0.08 || 0.8; min -= padPrice; max += padPrice; @@ -74,6 +96,7 @@ }; // K 线 + var bodyW = Math.max(1.5, Math.min(8, slot * 0.55)); bars.forEach(function (b, i) { var x = xOf(i); var up = b.close >= b.open; @@ -88,101 +111,122 @@ var y2 = yOf(b.close); var top = Math.min(y1, y2); var bh = Math.max(1, Math.abs(y2 - y1)); - ctx.fillRect(x - Math.max(1, slot * 0.3), top, Math.max(2, slot * 0.6), bh); + ctx.fillRect(x - bodyW / 2, top, bodyW, bh); }); - function drawPriceLine(price, color, label) { - if (price == null) return; - var y = yOf(price); - ctx.save(); - ctx.strokeStyle = color; - ctx.lineWidth = 1.2; - ctx.setLineDash([5, 4]); - ctx.beginPath(); - ctx.moveTo(padL, y); - ctx.lineTo(cssW - padR, y); - ctx.stroke(); - ctx.setLineDash([]); - ctx.fillStyle = color; - ctx.font = "11px Microsoft YaHei UI, sans-serif"; - ctx.textAlign = "left"; - ctx.textBaseline = "bottom"; - ctx.fillText(label + " " + price.toFixed(3), padL + 2, y - 2); - ctx.restore(); - } - - function drawMarker(i, price, color, label) { - if (i < 0 || price == null) return; - var x = xOf(i); - var y = yOf(price); - // 竖线标时刻 - ctx.save(); - ctx.strokeStyle = color; - ctx.globalAlpha = 0.35; - ctx.lineWidth = 1; - ctx.setLineDash([3, 3]); - ctx.beginPath(); - ctx.moveTo(x, padT); - ctx.lineTo(x, cssH - padB); - ctx.stroke(); - ctx.setLineDash([]); - ctx.globalAlpha = 1; - - // 圆点 - ctx.beginPath(); - ctx.arc(x, y, 5, 0, Math.PI * 2); - ctx.fillStyle = color; - ctx.fill(); - ctx.lineWidth = 1.5; - ctx.strokeStyle = "#ffffff"; - ctx.stroke(); - - // 三角指向 - ctx.beginPath(); - ctx.moveTo(x, y - 12); - ctx.lineTo(x - 5, y - 5); - ctx.lineTo(x + 5, y - 5); - ctx.closePath(); - ctx.fillStyle = color; - ctx.fill(); - - ctx.font = "bold 11px Microsoft YaHei UI, sans-serif"; - ctx.fillStyle = color; - ctx.textAlign = "center"; - ctx.textBaseline = "bottom"; - ctx.fillText(label, x, y - 14); - ctx.restore(); - } - var entryColor = "#4cc2ff"; var exitColor = "#f0b429"; var entryIdx = nearestBarIndex(bars, markers.openTs); - var exitIdx = nearestBarIndex(bars, markers.closeTs); - - drawPriceLine(markers.openPrice, entryColor, "入场"); - drawPriceLine(markers.closePrice, exitColor, "出场"); - drawMarker(entryIdx, markers.openPrice, entryColor, "入场"); - if (markers.closeTs != null && markers.closePrice != null) { - drawMarker(exitIdx, markers.closePrice, exitColor, "出场"); - } - - // 入场→出场连线(同图内) - if ( - entryIdx >= 0 && - exitIdx >= 0 && - markers.openPrice != null && + var exitIdx = + markers.closeTs != null ? nearestBarIndex(bars, markers.closeTs) : -1; + var hasExit = + markers.closeTs != null && markers.closePrice != null && - markers.closeTs != null - ) { + exitIdx >= 0; + + var openP = markers.openPrice; + var closeP = markers.closePrice; + var samePrice = + openP != null && + closeP != null && + Math.abs(openP - closeP) < 0.08; + + // 价格横线:价差很小时只画一条,避免两行字叠在一起 + function drawHLine(price, color) { + var y = yOf(price); ctx.save(); - ctx.strokeStyle = "#c084fc"; - ctx.lineWidth = 1.4; + ctx.strokeStyle = color; + ctx.globalAlpha = 0.7; + ctx.lineWidth = 1; ctx.setLineDash([4, 3]); ctx.beginPath(); - ctx.moveTo(xOf(entryIdx), yOf(markers.openPrice)); - ctx.lineTo(xOf(exitIdx), yOf(markers.closePrice)); + ctx.moveTo(padL, y); + ctx.lineTo(padL + w, y); ctx.stroke(); ctx.restore(); + return y; + } + + function drawRightTag(y, text, color, dy) { + dy = dy || 0; + var ty = Math.max(padT + 10, Math.min(cssH - padB - 4, y + dy)); + ctx.save(); + ctx.font = "11px Microsoft YaHei UI, sans-serif"; + ctx.fillStyle = color; + ctx.textAlign = "left"; + ctx.textBaseline = "middle"; + ctx.fillText(text, padL + w + 4, ty); + ctx.restore(); + } + + if (samePrice && openP != null) { + var ySame = drawHLine(openP, entryColor); + drawRightTag(ySame, "入/出 " + openP.toFixed(2), entryColor, 0); + } else { + if (openP != null) { + var yIn = drawHLine(openP, entryColor); + drawRightTag(yIn, "入 " + openP.toFixed(2), entryColor, 0); + } + if (hasExit && closeP != null) { + var yOut = drawHLine(closeP, exitColor); + var dy = 0; + if (openP != null && Math.abs(yOf(openP) - yOut) < 14) { + dy = yOut < yOf(openP) ? -12 : 12; + } + drawRightTag(yOut, "出 " + closeP.toFixed(2), exitColor, dy); + } + } + + // 点:无竖线、无三角、无紫连线 —— 只留圆点 + 短字 + function drawDot(i, price, color, label, labelSide) { + if (i < 0 || price == null) return; + var x = xOf(i); + var y = yOf(price); + ctx.save(); + ctx.beginPath(); + ctx.arc(x, y, 4, 0, Math.PI * 2); + ctx.fillStyle = color; + ctx.fill(); + ctx.lineWidth = 1.5; + ctx.strokeStyle = "#0e0e18"; + ctx.stroke(); + + ctx.font = "bold 10px Microsoft YaHei UI, sans-serif"; + ctx.fillStyle = color; + ctx.textBaseline = "bottom"; + if (labelSide === "left") { + ctx.textAlign = "right"; + ctx.fillText(label, x - 7, y - 4); + } else if (labelSide === "right") { + ctx.textAlign = "left"; + ctx.fillText(label, x + 7, y - 4); + } else { + ctx.textAlign = "center"; + ctx.fillText(label, x, y - 7); + } + ctx.restore(); + } + + var closeTogether = + hasExit && entryIdx >= 0 && Math.abs(exitIdx - entryIdx) <= 2; + + if (entryIdx >= 0 && openP != null) { + drawDot( + entryIdx, + openP, + entryColor, + "入", + closeTogether ? "left" : "center" + ); + } + if (hasExit) { + drawDot( + exitIdx, + closeP, + exitColor, + "出", + closeTogether ? "right" : "center" + ); } } @@ -200,7 +244,8 @@ document.querySelectorAll("canvas.chart-canvas").forEach(function (el) { try { var bars = JSON.parse(el.dataset.bars || "[]"); - drawCandleChart(el, bars, readMarkers(el)); + var tf = (el.dataset.tf || el.id || "").replace(/^chart-/, "") || "M1"; + drawCandleChart(el, bars, readMarkers(el), tf); } catch (e) { console.warn(e); } diff --git a/app/templates/record_detail.html b/app/templates/record_detail.html index 693eefb..79f000e 100644 --- a/app/templates/record_detail.html +++ b/app/templates/record_detail.html @@ -63,15 +63,15 @@

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

- K 线由客户端上报后写入服务器 bars 表保存;打开复盘时按开/平仓时间窗口读取。 - 蓝线/点=入场,黄线/点=出场,紫虚线=入场→出场。 + 客户端上报写入服务器;复盘读取。蓝=入场,黄=出场(价差很小时合并为一条「入/出」线)。图内只显示成交前后一段 K 线。

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

{{ tf }} · {{ charts[tf]|length }} 根

+

{{ tf }}

{% if charts[tf] %}