Mark entry/exit on review charts; clarify bars are saved then read.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-10-21 17:24:24 +08:00
parent 229f0351e0
commit 39bff20d60
3 changed files with 205 additions and 36 deletions
+10 -3
View File
@@ -928,12 +928,19 @@ 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)
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 = {} charts = {}
for tf in ("M1", "M5", "M15", "H1"): for tf in ("M1", "M5", "M15", "H1"):
charts[tf] = database.get_bars( 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( return render_template(
"record_detail.html", "record_detail.html",
+179 -25
View File
@@ -1,58 +1,212 @@
/** 轻量蜡烛图(无外部依赖),用于复盘四周期预览 */ /** 轻量蜡烛图(无外部依赖):复盘四周期 + 入场/出场标注 */
(function () { (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; if (!canvas || !bars || !bars.length) return;
const dpr = window.devicePixelRatio || 1; var dpr = window.devicePixelRatio || 1;
const cssW = canvas.clientWidth || 400; var cssW = canvas.clientWidth || 400;
const cssH = canvas.clientHeight || 180; var cssH = canvas.clientHeight || 180;
canvas.width = cssW * dpr; canvas.width = cssW * dpr;
canvas.height = cssH * dpr; canvas.height = cssH * dpr;
const ctx = canvas.getContext("2d"); var ctx = canvas.getContext("2d");
ctx.scale(dpr, dpr); ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const pad = 8; var padL = 8;
let min = Infinity, max = -Infinity; var padR = 8;
bars.forEach((b) => { var padT = 18;
var padB = 8;
var min = Infinity;
var max = -Infinity;
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) {
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)) { if (!(max > min)) {
max = min + 1; max = min + 1;
} }
const w = cssW - pad * 2; // 上下留一点边,避免标注贴边
const h = cssH - pad * 2; var padPrice = (max - min) * 0.04 || 0.5;
const n = bars.length; min -= padPrice;
const slot = w / n; 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.clearRect(0, 0, cssW, cssH);
ctx.fillStyle = "#0e0e18"; ctx.fillStyle = "#0e0e18";
ctx.fillRect(0, 0, cssW, cssH); 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) => { // K 线
const x = pad + i * slot + slot * 0.5; bars.forEach(function (b, i) {
const up = b.close >= b.open; var x = xOf(i);
var up = b.close >= b.open;
ctx.strokeStyle = up ? "#0ecb81" : "#f6465d"; ctx.strokeStyle = up ? "#0ecb81" : "#f6465d";
ctx.fillStyle = ctx.strokeStyle; ctx.fillStyle = ctx.strokeStyle;
ctx.lineWidth = 1;
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(x, yOf(b.high)); ctx.moveTo(x, yOf(b.high));
ctx.lineTo(x, yOf(b.low)); ctx.lineTo(x, yOf(b.low));
ctx.stroke(); ctx.stroke();
const y1 = yOf(b.open); var y1 = yOf(b.open);
const y2 = yOf(b.close); var y2 = yOf(b.close);
const top = Math.min(y1, y2); var top = Math.min(y1, y2);
const 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 - 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();
} }
document.querySelectorAll("canvas.chart-canvas").forEach((el) => { 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 { try {
const bars = JSON.parse(el.dataset.bars || "[]"); var bars = JSON.parse(el.dataset.bars || "[]");
drawCandleChart(el, bars); drawCandleChart(el, bars, readMarkers(el));
} catch (e) { } catch (e) {
console.warn(e); console.warn(e);
} }
}); });
}
paintAll();
window.addEventListener("resize", paintAll);
})(); })();
+11 -3
View File
@@ -62,16 +62,24 @@
<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">数据来自服务器缓存;由已激活客户端通过 API 上报 bars。无数据时显示占位。</p> <p class="muted">
K 线由客户端上报后<strong>写入服务器 bars 表保存</strong>;打开复盘时按开/平仓时间窗口<strong>读取</strong>。
蓝线/点=入场,黄线/点=出场,紫虚线=入场→出场。
</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 }} · {{ charts[tf]|length }} 根</h4>
{% if charts[tf] %} {% if charts[tf] %}
<canvas class="chart-canvas" id="chart-{{ tf }}" <canvas class="chart-canvas" id="chart-{{ tf }}"
data-bars='{{ charts[tf]|tojson }}'></canvas> 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 '' }}"></canvas>
{% else %} {% else %}
<div class="empty-chart">暂无 {{ tf }} K 线缓存</div> <div class="empty-chart">暂无 {{ tf }} K 线(需客户端上报后才有缓存)</div>
{% endif %} {% endif %}
</div> </div>
{% endfor %} {% endfor %}