23bd59c87f
Co-authored-by: Cursor <cursoragent@cursor.com>
346 lines
9.5 KiB
JavaScript
346 lines
9.5 KiB
JavaScript
/**
|
|
* 实例数据统计:复盘 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);
|