Mark entry/exit on review charts; clarify bars are saved then read.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+10
-3
@@ -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",
|
||||
|
||||
+179
-25
@@ -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();
|
||||
}
|
||||
|
||||
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 {
|
||||
const bars = JSON.parse(el.dataset.bars || "[]");
|
||||
drawCandleChart(el, bars);
|
||||
var bars = JSON.parse(el.dataset.bars || "[]");
|
||||
drawCandleChart(el, bars, readMarkers(el));
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
paintAll();
|
||||
window.addEventListener("resize", paintAll);
|
||||
})();
|
||||
|
||||
@@ -62,16 +62,24 @@
|
||||
|
||||
<div class="card" style="margin-top:14px">
|
||||
<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">
|
||||
{% for tf in ['M1','M5','M15','H1'] %}
|
||||
<div class="chart-box">
|
||||
<h4>{{ tf }} · {{ charts[tf]|length }} 根</h4>
|
||||
{% if charts[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 %}
|
||||
<div class="empty-chart">暂无 {{ tf }} K 线缓存</div>
|
||||
<div class="empty-chart">暂无 {{ tf }} K 线(需客户端上报后才有缓存)</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
Reference in New Issue
Block a user