新增实例数据统计页:复盘 KPI 上、资金曲线下,系统设置可关。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,345 @@
|
||||
/**
|
||||
* 实例数据统计:复盘 KPI + 资金曲线.
|
||||
*/
|
||||
(function (global) {
|
||||
"use strict";
|
||||
|
||||
var currentRange = "7d";
|
||||
var booted = false;
|
||||
|
||||
function $(id) {
|
||||
return document.getElementById(id);
|
||||
}
|
||||
|
||||
function root() {
|
||||
return $("options-data-stats");
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s == null ? "" : s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function fmtPnl(v) {
|
||||
if (v == null || v === "") return "—";
|
||||
var n = Number(v);
|
||||
if (Number.isNaN(n)) return "—";
|
||||
return (n >= 0 ? "+" : "") + n.toFixed(2) + "U";
|
||||
}
|
||||
|
||||
function fmtHold(sec) {
|
||||
if (sec == null) return "—";
|
||||
var s = Math.max(0, Number(sec) || 0);
|
||||
if (s < 3600) return Math.round(s / 60) + "m";
|
||||
if (s < 86400) return (s / 3600).toFixed(1) + "h";
|
||||
return (s / 86400).toFixed(1) + "d";
|
||||
}
|
||||
|
||||
function pnlClass(v) {
|
||||
var n = Number(v);
|
||||
if (Number.isNaN(n) || n === 0) return "";
|
||||
return n > 0 ? "ods-pnl-pos" : "ods-pnl-neg";
|
||||
}
|
||||
|
||||
function closeReasonLabel(r) {
|
||||
var map = {
|
||||
perp_tp: "永续止盈",
|
||||
perp_sl: "永续止损",
|
||||
oo_expiry_loss: "期期到期亏损",
|
||||
oo_expiry_win: "期期到期盈利",
|
||||
target_win_leg: "期期平盈利腿",
|
||||
profit_rr_win_leg: "期期盈亏比达标·平盈利腿",
|
||||
};
|
||||
return map[r] || String(r || "未填");
|
||||
}
|
||||
|
||||
function sourceType() {
|
||||
var el = root();
|
||||
return (el && el.getAttribute("data-source-type")) || "option_spot";
|
||||
}
|
||||
|
||||
function setStatus(text) {
|
||||
var el = $("ods-status");
|
||||
if (el) el.textContent = text || "";
|
||||
}
|
||||
|
||||
function renderGroup(title, items) {
|
||||
if (!items || !items.length) return "";
|
||||
var lines = items
|
||||
.slice(0, 8)
|
||||
.map(function (g) {
|
||||
var keyLabel =
|
||||
title === "对冲结束原因" ? closeReasonLabel(g.key) : String(g.key || "");
|
||||
return (
|
||||
'<div class="ods-stat-row">' +
|
||||
'<span class="ods-stat-key">' +
|
||||
escapeHtml(keyLabel) +
|
||||
" · " +
|
||||
g.count +
|
||||
"笔</span>" +
|
||||
'<span class="ods-stat-val">' +
|
||||
fmtPnl(g.pnl_sum) +
|
||||
" / 胜" +
|
||||
(g.win_rate || 0) +
|
||||
"%</span>" +
|
||||
"</div>"
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
return (
|
||||
'<div class="ods-stat-card"><div class="ods-stat-card-title">' +
|
||||
title +
|
||||
"</div>" +
|
||||
lines +
|
||||
"</div>"
|
||||
);
|
||||
}
|
||||
|
||||
function loadReviewStats() {
|
||||
var kpi = $("ods-kpi");
|
||||
var groups = $("ods-stats-groups");
|
||||
if (!kpi || !groups) return Promise.resolve();
|
||||
var qs = new URLSearchParams();
|
||||
qs.set("source_type", sourceType());
|
||||
qs.set("sync", "0");
|
||||
return fetch("/api/options/review/stats?" + qs.toString(), {
|
||||
credentials: "same-origin",
|
||||
})
|
||||
.then(function (r) {
|
||||
return r.json();
|
||||
})
|
||||
.then(function (data) {
|
||||
if (!data || !data.ok) {
|
||||
kpi.innerHTML = '<div class="muted" style="font-size:.76rem">统计加载失败</div>';
|
||||
groups.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
var k = data.kpi || {};
|
||||
kpi.innerHTML = [
|
||||
["笔数", k.total],
|
||||
["已复盘率", (k.review_rate || 0) + "%"],
|
||||
["胜率", (k.win_rate || 0) + "%"],
|
||||
["累计盈亏", fmtPnl(k.pnl_sum)],
|
||||
["平均盈亏", fmtPnl(k.avg_pnl)],
|
||||
["平均持有", fmtHold(k.avg_hold_sec)],
|
||||
]
|
||||
.map(function (pair) {
|
||||
var cls = "";
|
||||
if (pair[0] === "累计盈亏") cls = pnlClass(k.pnl_sum);
|
||||
if (pair[0] === "平均盈亏") cls = pnlClass(k.avg_pnl);
|
||||
return (
|
||||
'<div class="ods-kpi-tile"><div class="ods-kpi-label">' +
|
||||
pair[0] +
|
||||
'</div><div class="ods-kpi-value' +
|
||||
(cls ? " " + cls : "") +
|
||||
'">' +
|
||||
pair[1] +
|
||||
"</div></div>"
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
var html = [
|
||||
renderGroup("按类型", data.by_source_type),
|
||||
renderGroup("按标的", data.by_underlying),
|
||||
renderGroup("按策略", data.by_strategy),
|
||||
renderGroup("对冲结束原因", data.by_close_reason),
|
||||
renderGroup("持有周期", data.by_hold_bucket),
|
||||
renderGroup("Call/Put", data.by_opt_type),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("");
|
||||
groups.innerHTML =
|
||||
html || '<div class="muted" style="font-size:.76rem">暂无分组数据</div>';
|
||||
})
|
||||
.catch(function () {
|
||||
kpi.innerHTML = '<div class="muted" style="font-size:.76rem">统计加载失败</div>';
|
||||
groups.innerHTML = "";
|
||||
});
|
||||
}
|
||||
|
||||
function fmtCash(v) {
|
||||
if (v == null || Number.isNaN(Number(v))) return "—";
|
||||
return Number(v).toFixed(2) + "U";
|
||||
}
|
||||
|
||||
function drawCurve(points) {
|
||||
var svg = $("ods-curve-svg");
|
||||
if (!svg) return;
|
||||
var W = 640;
|
||||
var H = 240;
|
||||
var padL = 48;
|
||||
var padR = 12;
|
||||
var padT = 16;
|
||||
var padB = 28;
|
||||
var stroke = getComputedStyle(document.body).getPropertyValue("--accent") || "#5aa0ff";
|
||||
stroke = (stroke || "").trim() || "#5aa0ff";
|
||||
var grid = "rgba(127,127,127,.22)";
|
||||
var muted = "rgba(160,160,180,.75)";
|
||||
|
||||
if (!points || !points.length) {
|
||||
svg.innerHTML =
|
||||
'<text x="320" y="120" text-anchor="middle" fill="' +
|
||||
muted +
|
||||
'" font-size="13">暂无采样数据,请稍后再看</text>';
|
||||
return;
|
||||
}
|
||||
|
||||
var vals = points.map(function (p) {
|
||||
return Number(p.v);
|
||||
});
|
||||
var ts = points.map(function (p) {
|
||||
return Number(p.t);
|
||||
});
|
||||
var minV = Math.min.apply(null, vals);
|
||||
var maxV = Math.max.apply(null, vals);
|
||||
if (maxV - minV < 1e-6) {
|
||||
minV -= 1;
|
||||
maxV += 1;
|
||||
}
|
||||
var minT = ts[0];
|
||||
var maxT = ts[ts.length - 1];
|
||||
if (maxT <= minT) maxT = minT + 1;
|
||||
|
||||
function xOf(t) {
|
||||
return padL + ((t - minT) / (maxT - minT)) * (W - padL - padR);
|
||||
}
|
||||
function yOf(v) {
|
||||
return padT + (1 - (v - minV) / (maxV - minV)) * (H - padT - padB);
|
||||
}
|
||||
|
||||
var path = points
|
||||
.map(function (p, i) {
|
||||
var x = xOf(p.t);
|
||||
var y = yOf(p.v);
|
||||
return (i ? "L" : "M") + x.toFixed(1) + "," + y.toFixed(1);
|
||||
})
|
||||
.join(" ");
|
||||
|
||||
var yTicks = [minV, (minV + maxV) / 2, maxV];
|
||||
var gridHtml = yTicks
|
||||
.map(function (v) {
|
||||
var y = yOf(v);
|
||||
return (
|
||||
'<line x1="' +
|
||||
padL +
|
||||
'" y1="' +
|
||||
y.toFixed(1) +
|
||||
'" x2="' +
|
||||
(W - padR) +
|
||||
'" y2="' +
|
||||
y.toFixed(1) +
|
||||
'" stroke="' +
|
||||
grid +
|
||||
'" stroke-width="1"/>' +
|
||||
'<text x="' +
|
||||
(padL - 6) +
|
||||
'" y="' +
|
||||
(y + 4).toFixed(1) +
|
||||
'" text-anchor="end" fill="' +
|
||||
muted +
|
||||
'" font-size="10">' +
|
||||
Number(v).toFixed(0) +
|
||||
"</text>"
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
|
||||
var last = points[points.length - 1];
|
||||
svg.innerHTML =
|
||||
gridHtml +
|
||||
'<path d="' +
|
||||
path +
|
||||
'" fill="none" stroke="' +
|
||||
stroke +
|
||||
'" stroke-width="2" vector-effect="non-scaling-stroke"/>' +
|
||||
'<circle cx="' +
|
||||
xOf(last.t).toFixed(1) +
|
||||
'" cy="' +
|
||||
yOf(last.v).toFixed(1) +
|
||||
'" r="3.5" fill="' +
|
||||
stroke +
|
||||
'"/>';
|
||||
}
|
||||
|
||||
function loadCashCurve() {
|
||||
var meta = $("ods-curve-meta");
|
||||
return fetch("/api/instance/cash_curve?range=" + encodeURIComponent(currentRange), {
|
||||
credentials: "same-origin",
|
||||
})
|
||||
.then(function (r) {
|
||||
return r.json();
|
||||
})
|
||||
.then(function (data) {
|
||||
if (!data || !data.ok) {
|
||||
if (meta) meta.textContent = "曲线加载失败";
|
||||
drawCurve([]);
|
||||
return;
|
||||
}
|
||||
var pts = data.points || [];
|
||||
drawCurve(pts);
|
||||
if (meta) {
|
||||
meta.textContent =
|
||||
"采样 " +
|
||||
pts.length +
|
||||
" 点 · 最新 " +
|
||||
fmtCash(data.latest != null ? data.latest : pts.length ? pts[pts.length - 1].v : null);
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
if (meta) meta.textContent = "曲线加载失败";
|
||||
drawCurve([]);
|
||||
});
|
||||
}
|
||||
|
||||
function reloadAll() {
|
||||
setStatus("加载中…");
|
||||
return Promise.all([loadReviewStats(), loadCashCurve()]).then(function () {
|
||||
setStatus("");
|
||||
});
|
||||
}
|
||||
|
||||
function bindOnce() {
|
||||
if (booted || !root()) return;
|
||||
booted = true;
|
||||
var btn = $("ods-reload-btn");
|
||||
if (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
reloadAll();
|
||||
});
|
||||
}
|
||||
document.querySelectorAll("#options-data-stats .ods-range-btn").forEach(function (b) {
|
||||
b.addEventListener("click", function () {
|
||||
currentRange = b.getAttribute("data-range") || "7d";
|
||||
document.querySelectorAll("#options-data-stats .ods-range-btn").forEach(function (x) {
|
||||
x.classList.toggle("active", x === b);
|
||||
});
|
||||
loadCashCurve();
|
||||
});
|
||||
});
|
||||
// 默认 7 日更有曲线感;若用户点今日再切
|
||||
var def7 = document.querySelector('#options-data-stats .ods-range-btn[data-range="7d"]');
|
||||
if (def7) {
|
||||
document.querySelectorAll("#options-data-stats .ods-range-btn").forEach(function (x) {
|
||||
x.classList.toggle("active", x === def7);
|
||||
});
|
||||
currentRange = "7d";
|
||||
}
|
||||
reloadAll();
|
||||
}
|
||||
|
||||
function bootIfPresent() {
|
||||
if (root()) bindOnce();
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", bootIfPresent);
|
||||
} else {
|
||||
bootIfPresent();
|
||||
}
|
||||
|
||||
// embed 软切 tab 时脚本可能再次插入
|
||||
global.__OPTIONS_DATA_STATS_BOOT__ = bootIfPresent;
|
||||
})(typeof window !== "undefined" ? window : this);
|
||||
Reference in New Issue
Block a user