Clean up review charts: crop windows, simplify entry/exit markers.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-10-21 17:27:49 +08:00
parent 39bff20d60
commit 11c14fbb21
4 changed files with 162 additions and 109 deletions
+13 -5
View File
@@ -928,19 +928,27 @@ def register_routes(app: Flask) -> None:
flash("记录不存在或无权查看", "error") flash("记录不存在或无权查看", "error")
return redirect(url_for("records_page")) return redirect(url_for("records_page"))
_annotate_trade_times(trade) _annotate_trade_times(trade)
# 以开仓~平仓为中心取窗口,保证入场/出场都在图内 # 各周期不同前后窗口,避免 M1 过密、H1 过稀
open_ts = float( open_ts = float(
trade.get("open_time") or trade.get("created_at") or database.now() trade.get("open_time") or trade.get("created_at") or database.now()
) )
close_raw = trade.get("close_time") close_raw = trade.get("close_time")
close_ts = float(close_raw) if close_raw not in (None, "") else open_ts close_ts = float(close_raw) if close_raw not in (None, "") else open_ts
pad = 3600 * 3 tf_pad = {
start_ts = min(open_ts, close_ts) - pad "M1": 90 * 60, # ±90 分钟
end_ts = max(open_ts, close_ts) + pad "M5": 4 * 3600, # ±4 小时
"M15": 8 * 3600, # ±8 小时
"H1": 24 * 3600, # ±1 天
}
charts = {} charts = {}
for tf in ("M1", "M5", "M15", "H1"): for tf in ("M1", "M5", "M15", "H1"):
pad = tf_pad[tf]
charts[tf] = database.get_bars( 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( return render_template(
"record_detail.html", "record_detail.html",
+3 -3
View File
@@ -128,12 +128,12 @@ th { color: var(--muted); font-weight: 600; }
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 12px; border-radius: 12px;
padding: 10px; padding: 10px;
min-height: 220px; min-height: 240px;
} }
.chart-box h4 { margin: 0 0 8px; font-size: 13px; color: var(--muted); } .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 { .empty-chart {
height: 180px; height: 200px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
+143 -98
View File
@@ -1,5 +1,7 @@
/** 轻量蜡烛图(无外部依赖):复盘四周期 + 入场/出场标注 */ /** 复盘四周期蜡烛图:围绕成交裁剪窗口,入场/出场轻量标注 */
(function () { (function () {
var TF_MAX = { M1: 90, M5: 48, M15: 36, H1: 24 };
function num(v) { function num(v) {
if (v == null || v === "") return null; if (v == null || v === "") return null;
var n = Number(v); var n = Number(v);
@@ -20,27 +22,50 @@
return best; 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; if (!canvas || !bars || !bars.length) return;
bars = cropBars(bars, markers || {}, tf);
markers = markers || {};
var dpr = window.devicePixelRatio || 1; var dpr = window.devicePixelRatio || 1;
var cssW = canvas.clientWidth || 400; var cssW = canvas.clientWidth || 400;
var cssH = canvas.clientHeight || 180; var cssH = canvas.clientHeight || 200;
canvas.width = cssW * dpr; canvas.width = cssW * dpr;
canvas.height = cssH * dpr; canvas.height = cssH * dpr;
var ctx = canvas.getContext("2d"); var ctx = canvas.getContext("2d");
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
var padL = 8; var padL = 6;
var padR = 8; var padR = 56; // 右侧留给价签
var padT = 18; var padT = 10;
var padB = 8; var padB = 6;
var min = Infinity; var min = Infinity;
var max = -Infinity; var max = -Infinity;
bars.forEach(function (b) { bars.forEach(function (b) {
min = Math.min(min, b.low); min = Math.min(min, b.low);
max = Math.max(max, b.high); max = Math.max(max, b.high);
}); });
markers = markers || {};
if (markers.openPrice != null) { if (markers.openPrice != null) {
min = Math.min(min, markers.openPrice); min = Math.min(min, markers.openPrice);
max = Math.max(max, markers.openPrice); max = Math.max(max, markers.openPrice);
@@ -49,11 +74,8 @@
min = Math.min(min, markers.closePrice); min = Math.min(min, markers.closePrice);
max = Math.max(max, markers.closePrice); max = Math.max(max, markers.closePrice);
} }
if (!(max > min)) { if (!(max > min)) max = min + 1;
max = min + 1; var padPrice = (max - min) * 0.08 || 0.8;
}
// 上下留一点边,避免标注贴边
var padPrice = (max - min) * 0.04 || 0.5;
min -= padPrice; min -= padPrice;
max += padPrice; max += padPrice;
@@ -74,6 +96,7 @@
}; };
// K 线 // K 线
var bodyW = Math.max(1.5, Math.min(8, slot * 0.55));
bars.forEach(function (b, i) { bars.forEach(function (b, i) {
var x = xOf(i); var x = xOf(i);
var up = b.close >= b.open; var up = b.close >= b.open;
@@ -88,101 +111,122 @@
var y2 = yOf(b.close); var y2 = yOf(b.close);
var top = Math.min(y1, y2); var top = Math.min(y1, y2);
var bh = Math.max(1, Math.abs(y2 - y1)); 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 entryColor = "#4cc2ff";
var exitColor = "#f0b429"; var exitColor = "#f0b429";
var entryIdx = nearestBarIndex(bars, markers.openTs); var entryIdx = nearestBarIndex(bars, markers.openTs);
var exitIdx = nearestBarIndex(bars, markers.closeTs); var exitIdx =
markers.closeTs != null ? nearestBarIndex(bars, markers.closeTs) : -1;
drawPriceLine(markers.openPrice, entryColor, "入场"); var hasExit =
drawPriceLine(markers.closePrice, exitColor, "出场"); markers.closeTs != null &&
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.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.save();
ctx.strokeStyle = "#c084fc"; ctx.strokeStyle = color;
ctx.lineWidth = 1.4; ctx.globalAlpha = 0.7;
ctx.lineWidth = 1;
ctx.setLineDash([4, 3]); ctx.setLineDash([4, 3]);
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(xOf(entryIdx), yOf(markers.openPrice)); ctx.moveTo(padL, y);
ctx.lineTo(xOf(exitIdx), yOf(markers.closePrice)); ctx.lineTo(padL + w, y);
ctx.stroke(); ctx.stroke();
ctx.restore(); 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) { document.querySelectorAll("canvas.chart-canvas").forEach(function (el) {
try { try {
var bars = JSON.parse(el.dataset.bars || "[]"); 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) { } catch (e) {
console.warn(e); console.warn(e);
} }
+3 -3
View File
@@ -63,15 +63,15 @@
<div class="card" style="margin-top:14px"> <div class="card" style="margin-top:14px">
<h3>四周期 K 线(M1 / M5 / M15 / H1)</h3> <h3>四周期 K 线(M1 / M5 / M15 / H1)</h3>
<p class="muted"> <p class="muted">
K 线由客户端上报后<strong>写入服务器 bars 表保存</strong>;打开复盘时按开/平仓时间窗口<strong>读取</strong>。 客户端上报写入服务器;复盘读取。蓝=入场,黄=出场(价差很小时合并为一条「入/出」线)。图内只显示成交前后一段 K 线。
蓝线/点=入场,黄线/点=出场,紫虚线=入场→出场。
</p> </p>
<div class="chart-grid" style="margin-top:10px"> <div class="chart-grid" style="margin-top:10px">
{% for tf in ['M1','M5','M15','H1'] %} {% for tf in ['M1','M5','M15','H1'] %}
<div class="chart-box"> <div class="chart-box">
<h4>{{ tf }} · {{ charts[tf]|length }} 根</h4> <h4>{{ tf }}</h4>
{% if charts[tf] %} {% if charts[tf] %}
<canvas class="chart-canvas" id="chart-{{ tf }}" <canvas class="chart-canvas" id="chart-{{ tf }}"
data-tf="{{ tf }}"
data-bars='{{ charts[tf]|tojson }}' data-bars='{{ charts[tf]|tojson }}'
data-open-ts="{{ trade.open_time or '' }}" data-open-ts="{{ trade.open_time or '' }}"
data-close-ts="{{ trade.close_time or '' }}" data-close-ts="{{ trade.close_time or '' }}"