From 39bff20d6019a3698d6d3af5d3d07e26d4519598 Mon Sep 17 00:00:00 2001 From: dekun Date: Wed, 21 Oct 2026 17:24:24 +0800 Subject: [PATCH] Mark entry/exit on review charts; clarify bars are saved then read. Co-authored-by: Cursor --- app/routes.py | 13 +- app/static/js/charts.js | 214 ++++++++++++++++++++++++++----- app/templates/record_detail.html | 14 +- 3 files changed, 205 insertions(+), 36 deletions(-) diff --git a/app/routes.py b/app/routes.py index 0b84a8c..a2d648a 100644 --- a/app/routes.py +++ b/app/routes.py @@ -928,12 +928,19 @@ def register_routes(app: Flask) -> None: flash("记录不存在或无权查看", "error") return redirect(url_for("records_page")) _annotate_trade_times(trade) - center = trade.get("open_time") or trade.get("created_at") or database.now() - window = 3600 * 6 + # 以开仓~平仓为中心取窗口,保证入场/出场都在图内 + 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 charts = {} for tf in ("M1", "M5", "M15", "H1"): charts[tf] = database.get_bars( - conn, trade["symbol"], tf, float(center) - window, float(center) + window + conn, trade["symbol"], tf, start_ts, end_ts ) return render_template( "record_detail.html", diff --git a/app/static/js/charts.js b/app/static/js/charts.js index 7a70d93..fe07ff8 100644 --- a/app/static/js/charts.js +++ b/app/static/js/charts.js @@ -1,58 +1,212 @@ -/** 轻量蜡烛图(无外部依赖),用于复盘四周期预览 */ +/** 轻量蜡烛图(无外部依赖):复盘四周期 + 入场/出场标注 */ (function () { - function drawCandleChart(canvas, bars) { + function num(v) { + if (v == null || v === "") return null; + var n = Number(v); + return Number.isFinite(n) ? n : null; + } + + function nearestBarIndex(bars, ts) { + if (ts == null || !bars.length) return -1; + var best = 0; + var bestDiff = Math.abs(Number(bars[0].ts) - ts); + for (var i = 1; i < bars.length; i++) { + var d = Math.abs(Number(bars[i].ts) - ts); + if (d < bestDiff) { + bestDiff = d; + best = i; + } + } + return best; + } + + function drawCandleChart(canvas, bars, markers) { if (!canvas || !bars || !bars.length) return; - const dpr = window.devicePixelRatio || 1; - const cssW = canvas.clientWidth || 400; - const cssH = canvas.clientHeight || 180; + var dpr = window.devicePixelRatio || 1; + var cssW = canvas.clientWidth || 400; + var cssH = canvas.clientHeight || 180; canvas.width = cssW * dpr; canvas.height = cssH * dpr; - const ctx = canvas.getContext("2d"); - ctx.scale(dpr, dpr); + var ctx = canvas.getContext("2d"); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); - const pad = 8; - let min = Infinity, max = -Infinity; - bars.forEach((b) => { + var padL = 8; + var padR = 8; + var padT = 18; + var padB = 8; + 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); + } + if (markers.closePrice != null) { + min = Math.min(min, markers.closePrice); + max = Math.max(max, markers.closePrice); + } if (!(max > min)) { max = min + 1; } - const w = cssW - pad * 2; - const h = cssH - pad * 2; - const n = bars.length; - const slot = w / n; + // 上下留一点边,避免标注贴边 + var padPrice = (max - min) * 0.04 || 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); - const yOf = (p) => pad + ((max - p) / (max - min)) * h; + var yOf = function (p) { + return padT + ((max - p) / (max - min)) * h; + }; + var xOf = function (i) { + return padL + i * slot + slot * 0.5; + }; - bars.forEach((b, i) => { - const x = pad + i * slot + slot * 0.5; - const up = b.close >= b.open; + // K 线 + 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(); - const y1 = yOf(b.open); - const y2 = yOf(b.close); - const top = Math.min(y1, y2); - const bh = Math.max(1, Math.abs(y2 - y1)); + 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 - Math.max(1, slot * 0.3), top, Math.max(2, slot * 0.6), 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 && + markers.closePrice != null && + markers.closeTs != null + ) { + ctx.save(); + ctx.strokeStyle = "#c084fc"; + ctx.lineWidth = 1.4; + ctx.setLineDash([4, 3]); + ctx.beginPath(); + ctx.moveTo(xOf(entryIdx), yOf(markers.openPrice)); + ctx.lineTo(xOf(exitIdx), yOf(markers.closePrice)); + ctx.stroke(); + ctx.restore(); + } + } + + 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 || "[]"); + drawCandleChart(el, bars, readMarkers(el)); + } catch (e) { + console.warn(e); + } + }); } - document.querySelectorAll("canvas.chart-canvas").forEach((el) => { - try { - const bars = JSON.parse(el.dataset.bars || "[]"); - drawCandleChart(el, bars); - } catch (e) { - console.warn(e); - } - }); + paintAll(); + window.addEventListener("resize", paintAll); })(); diff --git a/app/templates/record_detail.html b/app/templates/record_detail.html index a08a400..693eefb 100644 --- a/app/templates/record_detail.html +++ b/app/templates/record_detail.html @@ -62,16 +62,24 @@

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

-

数据来自服务器缓存;由已激活客户端通过 API 上报 bars。无数据时显示占位。

+

+ K 线由客户端上报后写入服务器 bars 表保存;打开复盘时按开/平仓时间窗口读取。 + 蓝线/点=入场,黄线/点=出场,紫虚线=入场→出场。 +

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

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

{% if charts[tf] %} + data-bars='{{ charts[tf]|tojson }}' + data-open-ts="{{ trade.open_time or '' }}" + data-close-ts="{{ trade.close_time or '' }}" + data-open-price="{{ trade.open_price if trade.open_price is not none else '' }}" + data-close-price="{{ trade.close_price if trade.close_price is not none else '' }}" + data-side="{{ trade.side or '' }}"> {% else %} -
暂无 {{ tf }} K 线缓存
+
暂无 {{ tf }} K 线(需客户端上报后才有缓存)
{% endif %}
{% endfor %}