2582 lines
86 KiB
JavaScript
2582 lines
86 KiB
JavaScript
const API = "";
|
||
let selectedDataFile = null;
|
||
let selectedSymbol = null;
|
||
let selectedStrategyFile = null;
|
||
let selectedStrategySymbol = null;
|
||
let chart = null;
|
||
let chartSymbol = null;
|
||
let pollTimer = null;
|
||
let clientErrors = [];
|
||
let debugMode = false;
|
||
let lastDebugViewContent = "";
|
||
|
||
// 分页与回测状态
|
||
let currentPage = "train";
|
||
let btActive = false;
|
||
let btBuster = ""; // 图表缓存刷新键(用 job 时间戳)
|
||
let btPortfolioSig = ""; // 绩效卡签名:变化时才重建 + 播放数字动画,避免每次轮询重播
|
||
let lastEquityData = null; // 最近一次资金曲线数据,供绩效卡 sparkline 复用
|
||
let lastTrainingActive = false;
|
||
let btLastAlertKey = "";
|
||
let lastErrorPopupText = "";
|
||
let lastErrorPopupAt = 0;
|
||
|
||
const $ = (id) => document.getElementById(id);
|
||
|
||
const CPU_TRAINING_NOTE = `暂无报错
|
||
|
||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||
【为什么用 CPU 训练,不用 GPU?】
|
||
|
||
你可以把 GPU 想象成一辆超大的货车,CPU 想象成一辆灵活的小电瓶车。
|
||
|
||
我们这个项目的训练,就像要做很多很多道「小题」:
|
||
每道题只算一点点数字,算完马上换下一道。
|
||
货车虽然一次能装很多,但每装卸一次都要准备很久才能再出发;
|
||
电瓶车一次装的少,但说走就走,一道接一道做,反而更快。
|
||
|
||
再打个比方:
|
||
GPU 像很多厨师一起做大锅饭,适合一次炒一大锅;
|
||
我们这个训练更像一道道菜分开炒,而且每道菜份量很小。
|
||
大锅饭团队每次开火、洗锅、集合都要时间,小菜一碟反而耽误在「准备」上。
|
||
|
||
所以具体原因是:
|
||
1. 每次要算的数据不多,GPU「启动一次计算」的等待,有时比真正算数还久。
|
||
2. 训练是一步接一步、一条公式接一条公式地指挥,GPU 经常闲着等下一道题,没法一直满负荷。
|
||
3. 数据还要在 CPU 和 GPU 之间来回搬运,也要花时间。
|
||
|
||
我们实测过(同样训练 50 步):GPU 大约 4.5 秒一步,CPU 大约 1.9 秒一步。
|
||
这不是显卡坏了,也不是没装驱动,而是这个项目的做题方式,更适合 CPU。
|
||
|
||
说白了就是这个项目用CPU训练的速度比用GPU训练的速度更快`;
|
||
|
||
function emptyDebugMessage() {
|
||
return debugMode ? "暂无日志" : CPU_TRAINING_NOTE;
|
||
}
|
||
|
||
function formatApiError(data, status, path) {
|
||
const d = data?.detail;
|
||
let detail = "";
|
||
if (Array.isArray(d)) {
|
||
detail = d.map((x) => x.msg || JSON.stringify(x)).join("; ");
|
||
} else if (typeof d === "string") {
|
||
detail = d;
|
||
} else if (d) {
|
||
detail = JSON.stringify(d);
|
||
}
|
||
if (data?.traceback) {
|
||
detail += `\n\n${data.traceback}`;
|
||
}
|
||
return detail || `HTTP ${status} ${path}`;
|
||
}
|
||
|
||
async function logClientError(message, context = {}) {
|
||
const entry = `[${new Date().toLocaleString()}] ${message}`;
|
||
clientErrors.push(entry);
|
||
if (clientErrors.length > 80) clientErrors = clientErrors.slice(-80);
|
||
renderDebugView();
|
||
const silent = !!context.silent;
|
||
if (!silent) {
|
||
const detail = context.detail ? `${message}\n\n${context.detail}` : message;
|
||
showErrorPopup("出错了", detail);
|
||
}
|
||
try {
|
||
await fetch(API + "/api/debug/client-log", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ level: "error", message, context }),
|
||
});
|
||
} catch (_) {
|
||
/* server may be down */
|
||
}
|
||
}
|
||
|
||
function showErrorPopup(title, detail) {
|
||
const modal = $("errorModal");
|
||
const titleEl = $("errorModalTitle");
|
||
const detailEl = $("errorModalDetail");
|
||
if (!modal || !detailEl) {
|
||
window.alert(`${title}\n\n${detail}`);
|
||
return;
|
||
}
|
||
const text = String(detail || "").trim() || "未知错误";
|
||
const now = Date.now();
|
||
if (text === lastErrorPopupText && now - lastErrorPopupAt < 2500) return;
|
||
lastErrorPopupText = text;
|
||
lastErrorPopupAt = now;
|
||
if (titleEl) titleEl.textContent = title || "出错了";
|
||
detailEl.textContent = text;
|
||
modal.hidden = false;
|
||
}
|
||
|
||
function closeErrorPopup() {
|
||
const modal = $("errorModal");
|
||
if (modal) modal.hidden = true;
|
||
}
|
||
|
||
async function copyErrorPopupDetail() {
|
||
const text = $("errorModalDetail")?.textContent || "";
|
||
if (!text) return;
|
||
try {
|
||
await navigator.clipboard.writeText(text);
|
||
} catch (_) {
|
||
const ta = document.createElement("textarea");
|
||
ta.value = text;
|
||
document.body.appendChild(ta);
|
||
ta.select();
|
||
document.execCommand("copy");
|
||
ta.remove();
|
||
}
|
||
}
|
||
|
||
function isViewAtBottom(el, threshold = 40) {
|
||
return el.scrollHeight - el.scrollTop - el.clientHeight < threshold;
|
||
}
|
||
|
||
function renderDebugView(serverLines = [], errorLines = []) {
|
||
const parts = [];
|
||
if (clientErrors.length) {
|
||
parts.push("=== 前端报错 ===", ...clientErrors);
|
||
}
|
||
if (errorLines.length) {
|
||
parts.push("\n=== 服务端错误日志 (logs/web_errors.log) ===", ...errorLines);
|
||
}
|
||
if (debugMode && serverLines.length) {
|
||
parts.push("\n=== 服务端运行日志 (logs/web_server.log) ===", ...serverLines);
|
||
}
|
||
const el = $("debugView");
|
||
const atBottom = isViewAtBottom(el);
|
||
const next = parts.length ? parts.join("\n") : emptyDebugMessage();
|
||
const changed = next !== lastDebugViewContent;
|
||
el.textContent = next;
|
||
if (changed && atBottom && lastDebugViewContent) {
|
||
el.scrollTop = el.scrollHeight;
|
||
}
|
||
lastDebugViewContent = next;
|
||
}
|
||
|
||
async function setDebugMode(enabled) {
|
||
debugMode = !!enabled;
|
||
$("debugModeCheck").checked = debugMode;
|
||
try {
|
||
await fetchJSON("/api/settings", {
|
||
method: "PUT",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ debug_mode: debugMode }),
|
||
});
|
||
} catch (e) {
|
||
await logClientError("切换调试模式失败: " + e.message);
|
||
}
|
||
if (!debugMode) {
|
||
renderDebugView([], []);
|
||
} else {
|
||
await refreshDebugLogs();
|
||
}
|
||
}
|
||
|
||
async function refreshDebugLogs() {
|
||
try {
|
||
const data = await fetch(API + "/api/debug/logs?lines=120").then(async (res) => {
|
||
const json = await res.json().catch(() => ({}));
|
||
if (!res.ok) throw new Error(formatApiError(json, res.status, "/api/debug/logs"));
|
||
return json;
|
||
});
|
||
$("debugLogPaths").textContent = `本地: ${data.error_log}`;
|
||
renderDebugView(data.server_tail || [], data.error_tail || []);
|
||
} catch (e) {
|
||
renderDebugView();
|
||
}
|
||
}
|
||
|
||
async function fetchJSON(path, opts = {}) {
|
||
const silent = !!opts.silent;
|
||
const maxRetries = opts.retries != null ? Number(opts.retries) : 5;
|
||
const retryDelayMs = opts.retryDelayMs != null ? Number(opts.retryDelayMs) : 2000;
|
||
const fetchOpts = { ...opts };
|
||
delete fetchOpts.silent;
|
||
delete fetchOpts.retries;
|
||
delete fetchOpts.retryDelayMs;
|
||
|
||
let lastNetworkMsg = null;
|
||
for (let attempt = 1; attempt <= Math.max(1, maxRetries); attempt++) {
|
||
let res;
|
||
try {
|
||
res = await fetch(API + path, fetchOpts);
|
||
} catch (e) {
|
||
lastNetworkMsg = `网络错误 ${path}: ${e.message}`;
|
||
if (attempt < maxRetries) {
|
||
await new Promise((r) => setTimeout(r, retryDelayMs));
|
||
continue;
|
||
}
|
||
await logClientError(lastNetworkMsg, { path, silent, attempts: attempt });
|
||
throw new Error(lastNetworkMsg);
|
||
}
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) {
|
||
const msg = formatApiError(data, res.status, path);
|
||
await logClientError(`${path} -> ${msg}`, { path, status: res.status, silent });
|
||
if (!silent) await refreshDebugLogs();
|
||
throw new Error(msg);
|
||
}
|
||
return data;
|
||
}
|
||
throw new Error(lastNetworkMsg || `网络错误 ${path}`);
|
||
}
|
||
|
||
function formatScore(v) {
|
||
if (v == null || Number.isNaN(v)) return "—";
|
||
return Number(v).toFixed(4);
|
||
}
|
||
|
||
function renderDataFileCard(info) {
|
||
const card = $("dataFileCard");
|
||
const startBtn = $("startBtn");
|
||
|
||
if (!info || !info.data_file) {
|
||
card.className = "data-file-card";
|
||
card.innerHTML = '<div class="data-file-empty">尚未选择数据文件</div>';
|
||
selectedDataFile = null;
|
||
selectedSymbol = null;
|
||
startBtn.disabled = true;
|
||
if ($("retrainBtn")) $("retrainBtn").disabled = true;
|
||
if ($("exportBtn")) $("exportBtn").disabled = true;
|
||
if ($("exportTrainingBtn")) $("exportTrainingBtn").disabled = true;
|
||
if ($("importTrainingBtn")) $("importTrainingBtn").disabled = true;
|
||
return;
|
||
}
|
||
|
||
selectedDataFile = info.data_file;
|
||
selectedSymbol = info.symbol || null;
|
||
|
||
if (info.valid === false) {
|
||
card.className = "data-file-card invalid";
|
||
card.innerHTML = `
|
||
<div class="data-file-error">${info.message || "文件无效"}</div>
|
||
<div class="data-file-path">${info.data_file}</div>
|
||
`;
|
||
startBtn.disabled = true;
|
||
if ($("retrainBtn")) $("retrainBtn").disabled = true;
|
||
if ($("exportTrainingBtn")) $("exportTrainingBtn").disabled = true;
|
||
if ($("importTrainingBtn")) $("importTrainingBtn").disabled = true;
|
||
return;
|
||
}
|
||
|
||
card.className = "data-file-card valid";
|
||
const yearsText = info.years_h1 != null ? `${info.years_h1} 年` : "—";
|
||
card.innerHTML = `
|
||
<div class="data-file-row">
|
||
<div class="item"><span class="label">品种</span><span class="value sym">${info.symbol}</span></div>
|
||
<div class="item"><span class="label">周期</span><span class="value">${info.timeframe}</span></div>
|
||
<div class="item"><span class="label">K线</span><span class="value">${info.bars?.toLocaleString()}</span></div>
|
||
<div class="item"><span class="label">数据年限</span><span class="value">${yearsText}</span></div>
|
||
<div class="item"><span class="label">进度</span><span class="value" id="fileProgressPct">—</span></div>
|
||
<div class="item"><span class="label">本次训练时长</span><span class="value" id="fileElapsedTime">—</span></div>
|
||
<div class="item"><span class="label">历史训练总时长</span><span class="value" id="fileHistoryElapsedTime">—</span></div>
|
||
<div class="item"><span class="label">最优分数</span><span class="value score-best" id="fileBestScore">—</span></div>
|
||
<div class="item"><span class="label">验证分数</span><span class="value score-val" id="fileValScore">—</span></div>
|
||
</div>
|
||
<div class="path" title="${info.data_file}">${info.filename || info.data_file}</div>
|
||
`;
|
||
startBtn.disabled = false;
|
||
if ($("retrainBtn")) $("retrainBtn").disabled = false;
|
||
}
|
||
|
||
function updateBtStartBtn() {
|
||
const startBtn = $("btStartBtn");
|
||
if (!startBtn) return;
|
||
startBtn.disabled = btActive || !selectedStrategyFile;
|
||
["btCommissionInput", "btSlippageInput"].forEach((id) => {
|
||
const el = $(id);
|
||
if (el) el.disabled = btActive;
|
||
});
|
||
}
|
||
|
||
function renderStrategyFileCard(info) {
|
||
const card = $("btStrategyCard");
|
||
if (!card) return;
|
||
|
||
if (!info || !info.strategy_file) {
|
||
card.className = "data-file-card";
|
||
card.innerHTML = '<div class="data-file-empty">尚未选择策略文件</div>';
|
||
selectedStrategyFile = null;
|
||
selectedStrategySymbol = null;
|
||
updateBtStartBtn();
|
||
return;
|
||
}
|
||
|
||
if (info.valid === false) {
|
||
card.className = "data-file-card invalid";
|
||
card.innerHTML = `
|
||
<div class="data-file-error">${info.message || "文件无效"}</div>
|
||
<div class="data-file-path">${info.strategy_file}</div>
|
||
`;
|
||
selectedStrategyFile = null;
|
||
selectedStrategySymbol = null;
|
||
updateBtStartBtn();
|
||
return;
|
||
}
|
||
|
||
selectedStrategyFile = info.strategy_file;
|
||
selectedStrategySymbol = info.symbol || null;
|
||
card.className = "data-file-card valid";
|
||
const timeframeItem = info.timeframe
|
||
? `<div class="item"><span class="label">周期</span><span class="value">${info.timeframe}</span></div>`
|
||
: "";
|
||
const dataPath = info.data_file || "";
|
||
const dataOk = info.data_file_exists;
|
||
const dataHint = dataPath
|
||
? (dataOk ? dataPath : `(文件不存在)${dataPath}`)
|
||
: "未记录数据路径 — 回测前请先在训练页选择同品种 Parquet";
|
||
card.innerHTML = `
|
||
<div class="data-file-row">
|
||
<div class="item"><span class="label">品种</span><span class="value sym">${info.symbol || "—"}</span></div>
|
||
${timeframeItem}
|
||
<div class="item"><span class="label">最优分数</span><span class="value score-best">${formatScore(info.best_score)}</span></div>
|
||
<div class="item"><span class="label">词表版本</span><span class="value">${info.vocab_version || "—"}</span></div>
|
||
<div class="item"><span class="label">公式长度</span><span class="value">${info.formula_decoded ? info.formula_decoded.split("→").length : "—"}</span></div>
|
||
</div>
|
||
<div class="path" title="${info.strategy_file}">策略: ${info.filename || info.strategy_file}</div>
|
||
<div class="path ${dataPath && dataOk ? "" : "data-file-missing"}" title="${dataPath || ""}">数据: ${dataHint}</div>
|
||
`;
|
||
updateBtStartBtn();
|
||
}
|
||
|
||
function formatElapsed(startedAtIso, endAtIso) {
|
||
if (!startedAtIso) return "—";
|
||
const started = new Date(startedAtIso).getTime();
|
||
if (Number.isNaN(started)) return "—";
|
||
const end = endAtIso ? new Date(endAtIso).getTime() : Date.now();
|
||
if (Number.isNaN(end)) return "—";
|
||
return formatDurationSeconds(Math.max(0, Math.floor((end - started) / 1000)));
|
||
}
|
||
|
||
function formatDurationSeconds(secs) {
|
||
if (secs == null || secs < 0) return "—";
|
||
const total = Math.floor(secs);
|
||
const h = Math.floor(total / 3600);
|
||
const m = Math.floor((total % 3600) / 60);
|
||
if (h > 0) return `${h}小时${m}分`;
|
||
if (m > 0) return `${m}分钟`;
|
||
return `${total}秒`;
|
||
}
|
||
|
||
function updateTrainingTimeFields(progress, training) {
|
||
const sessionEl = $("fileElapsedTime");
|
||
const historyEl = $("fileHistoryElapsedTime");
|
||
if (!sessionEl && !historyEl) return;
|
||
|
||
if (historyEl) {
|
||
const hist = progress?.history_total_seconds;
|
||
historyEl.textContent = hist != null ? formatDurationSeconds(hist) : "—";
|
||
}
|
||
|
||
const job = training?.job;
|
||
const active = !!training?.active;
|
||
if (!sessionEl) return;
|
||
|
||
if (!job || job.state === "idle") {
|
||
sessionEl.textContent = "—";
|
||
return;
|
||
}
|
||
|
||
const elapsed = formatElapsed(job.started_at, active ? null : job.finished_at);
|
||
sessionEl.textContent = active || elapsed === "—" ? elapsed : `${elapsed}(已停)`;
|
||
}
|
||
|
||
function updateFileProgress(progress) {
|
||
const el = document.getElementById("fileProgressPct");
|
||
if (el && progress) {
|
||
el.textContent = `${progress.current_step} / ${progress.train_steps} (${progress.progress_pct}%)`;
|
||
}
|
||
const bestEl = document.getElementById("fileBestScore");
|
||
if (bestEl) {
|
||
bestEl.textContent = progress ? formatScore(progress.best_score) : "—";
|
||
}
|
||
const valEl = document.getElementById("fileValScore");
|
||
if (valEl) {
|
||
let val = progress?.val_score;
|
||
if (val == null && progress?.history?.val_score?.length) {
|
||
val = progress.history.val_score[progress.history.val_score.length - 1];
|
||
}
|
||
valEl.textContent = progress ? formatScore(val) : "—";
|
||
}
|
||
}
|
||
|
||
const CHART_SERIES = [
|
||
{ key: "best_score", label: "最优分数", borderColor: "#34f5c8", fillRGB: "52, 245, 200", yAxisID: "y" },
|
||
{ key: "val_score", label: "验证分数", borderColor: "#38bdf8", fillRGB: "56, 189, 248", yAxisID: "y" },
|
||
];
|
||
|
||
// 让曲线在填充区形成竖向渐变
|
||
function makeGradient(ctx, area, rgb) {
|
||
if (!area) return `rgba(${rgb}, 0.08)`;
|
||
const g = ctx.createLinearGradient(0, area.top, 0, area.bottom);
|
||
g.addColorStop(0, `rgba(${rgb}, 0.28)`);
|
||
g.addColorStop(0.6, `rgba(${rgb}, 0.06)`);
|
||
g.addColorStop(1, `rgba(${rgb}, 0)`);
|
||
return g;
|
||
}
|
||
|
||
// 发光效果:在每条数据线绘制前设置对应颜色的柔和阴影
|
||
const glowPlugin = {
|
||
id: "neonGlow",
|
||
beforeDatasetDraw(chart, args) {
|
||
const color = args?.meta?.dataset?.options?.borderColor;
|
||
const ctx = chart.ctx;
|
||
ctx.save();
|
||
if (typeof color === "string") {
|
||
ctx.shadowColor = color;
|
||
ctx.shadowBlur = 10;
|
||
}
|
||
},
|
||
afterDatasetDraw(chart) {
|
||
chart.ctx.restore();
|
||
},
|
||
};
|
||
if (window.Chart) Chart.register(glowPlugin);
|
||
|
||
const CHART_OPTIONS = {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
interaction: { mode: "index", intersect: false },
|
||
animation: { duration: 450, easing: "easeOutQuart" },
|
||
transitions: {
|
||
active: { animation: { duration: 450, easing: "easeOutQuart" } },
|
||
},
|
||
plugins: {
|
||
legend: {
|
||
labels: {
|
||
color: "#a9bccf",
|
||
usePointStyle: true,
|
||
pointStyle: "circle",
|
||
boxWidth: 8,
|
||
boxHeight: 8,
|
||
padding: 16,
|
||
font: { family: "'DM Sans'", size: 12, weight: "600" },
|
||
},
|
||
},
|
||
tooltip: {
|
||
backgroundColor: "rgba(8, 12, 20, 0.92)",
|
||
borderColor: "rgba(94, 234, 212, 0.35)",
|
||
borderWidth: 1,
|
||
titleColor: "#e8edf4",
|
||
bodyColor: "#a9bccf",
|
||
titleFont: { family: "'JetBrains Mono'", size: 11 },
|
||
bodyFont: { family: "'JetBrains Mono'", size: 11 },
|
||
padding: 10,
|
||
cornerRadius: 8,
|
||
displayColors: true,
|
||
usePointStyle: true,
|
||
},
|
||
},
|
||
scales: {
|
||
x: {
|
||
ticks: { color: "#6b7d92", maxTicksLimit: 8, font: { family: "'JetBrains Mono'", size: 10 } },
|
||
grid: { color: "rgba(120,190,235,0.05)" },
|
||
border: { color: "rgba(120,190,235,0.12)" },
|
||
},
|
||
y: {
|
||
title: { display: true, text: "分数(最优 / 验证)", color: "#7dd3fc", font: { family: "'DM Sans'", size: 10, weight: "600" } },
|
||
ticks: { color: "#6b7d92", font: { family: "'JetBrains Mono'", size: 10 } },
|
||
grid: { color: "rgba(120,190,235,0.05)" },
|
||
border: { color: "rgba(120,190,235,0.12)" },
|
||
},
|
||
},
|
||
};
|
||
|
||
function buildChartDatasets(history) {
|
||
return CHART_SERIES.filter((s) => history?.[s.key]?.length).map((s) => ({
|
||
label: s.label,
|
||
data: history[s.key],
|
||
borderColor: s.borderColor,
|
||
borderWidth: 2,
|
||
tension: 0.35,
|
||
pointRadius: 0,
|
||
pointHoverRadius: 4,
|
||
pointHoverBackgroundColor: s.borderColor,
|
||
pointHoverBorderColor: "#05070d",
|
||
fill: true,
|
||
backgroundColor: (context) => {
|
||
const { ctx, chartArea } = context.chart;
|
||
return makeGradient(ctx, chartArea, s.fillRGB);
|
||
},
|
||
yAxisID: s.yAxisID,
|
||
}));
|
||
}
|
||
|
||
function destroyChart() {
|
||
if (chart) {
|
||
chart.destroy();
|
||
chart = null;
|
||
}
|
||
chartSymbol = null;
|
||
}
|
||
|
||
function createChart(ctx, steps, history) {
|
||
return new Chart(ctx, {
|
||
type: "line",
|
||
data: { labels: steps, datasets: buildChartDatasets(history) },
|
||
options: CHART_OPTIONS,
|
||
});
|
||
}
|
||
|
||
function updateChartInPlace(steps, history) {
|
||
const prevLen = chart.data.labels.length;
|
||
chart.data.labels = steps;
|
||
|
||
const next = buildChartDatasets(history);
|
||
for (const ds of next) {
|
||
const existing = chart.data.datasets.find((d) => d.label === ds.label);
|
||
if (existing) {
|
||
existing.data = ds.data;
|
||
} else {
|
||
chart.data.datasets.push(ds);
|
||
}
|
||
}
|
||
|
||
const nextLabels = new Set(next.map((d) => d.label));
|
||
chart.data.datasets = chart.data.datasets.filter((d) => nextLabels.has(d.label));
|
||
|
||
const grew = steps.length > prevLen;
|
||
chart.update(grew ? "active" : "none");
|
||
}
|
||
|
||
function renderChart(history, label, progress) {
|
||
const ctx = $("mainChart").getContext("2d");
|
||
const steps = history?.step || [];
|
||
if (!steps.length) {
|
||
destroyChart();
|
||
if (progress?.current_step > 0) {
|
||
$("chartHint").textContent = `训练中 第 ${progress.current_step}/${progress.train_steps} 步,曲线每步更新`;
|
||
} else {
|
||
$("chartHint").textContent = "暂无历史数据(首步约需 15–30 秒)";
|
||
}
|
||
return;
|
||
}
|
||
|
||
const sameSymbol = chart && chartSymbol === label;
|
||
if (sameSymbol) {
|
||
updateChartInPlace(steps, history);
|
||
} else {
|
||
destroyChart();
|
||
chart = createChart(ctx, steps, history);
|
||
chartSymbol = label;
|
||
}
|
||
|
||
$("chartTitle").textContent = `${label} 训练曲线`;
|
||
$("chartHint").textContent = `${steps.length} 个记录点`;
|
||
}
|
||
|
||
async function loadSymbolChart(symbol, progress) {
|
||
if (!symbol) return;
|
||
try {
|
||
const data = await fetchJSON(`/api/symbols/${encodeURIComponent(symbol)}`);
|
||
renderChart(data.history, symbol, progress || data);
|
||
$("formulaText").textContent = data.formula_decoded || "—";
|
||
} catch (e) {
|
||
$("formulaText").textContent = "—";
|
||
}
|
||
}
|
||
|
||
function renderStrategies(rows) {
|
||
const tbody = $("strategiesBody");
|
||
if (!rows.length) {
|
||
tbody.innerHTML = '<tr class="empty-row"><td colspan="4">暂无已保存策略</td></tr>';
|
||
return;
|
||
}
|
||
tbody.innerHTML = rows
|
||
.map(
|
||
(r) => `
|
||
<tr>
|
||
<td>${r.symbol}</td>
|
||
<td>${r.timeframe || "—"}</td>
|
||
<td>${formatScore(r.best_score)}</td>
|
||
<td><code>${r.formula_decoded || "—"}</code></td>
|
||
</tr>`
|
||
)
|
||
.join("");
|
||
}
|
||
|
||
function updateTrainingUI(training, progress) {
|
||
const job = training?.job;
|
||
const active = training?.active;
|
||
const pill = $("jobPill");
|
||
const startBtn = $("startBtn");
|
||
const retrainBtn = $("retrainBtn");
|
||
const stopBtn = $("stopBtn");
|
||
|
||
if (!job || job.state === "idle") {
|
||
pill.innerHTML = '<i class="pill-dot"></i>空闲';
|
||
pill.className = "pill";
|
||
startBtn.disabled = !selectedDataFile;
|
||
if (retrainBtn) retrainBtn.disabled = !selectedDataFile;
|
||
stopBtn.disabled = true;
|
||
$("logHint").textContent = "—";
|
||
updateTrainingTimeFields(progress, training);
|
||
return;
|
||
}
|
||
|
||
const stateLabel = {
|
||
running: "训练中",
|
||
completed: "已完成",
|
||
failed: "失败",
|
||
stopped: "已停止",
|
||
};
|
||
const label = job.symbol ? `${job.symbol} ${job.timeframe || ""}`.trim() : "训练";
|
||
const stateText = stateLabel[job.state] || job.state;
|
||
pill.innerHTML = `<i class="pill-dot"></i>${stateText} · ${label}`;
|
||
pill.className = "pill " + (job.state === "running" ? "running" : job.state);
|
||
|
||
startBtn.disabled = active;
|
||
if (retrainBtn) retrainBtn.disabled = active;
|
||
stopBtn.disabled = !active;
|
||
$("logHint").textContent = job.log_path || "—";
|
||
updateTrainingTimeFields(progress, training);
|
||
|
||
const logView = $("logView");
|
||
const atBottom = isViewAtBottom(logView);
|
||
logView.textContent = (training.log_tail || []).join("\n") || "等待输出…";
|
||
if (atBottom) logView.scrollTop = logView.scrollHeight;
|
||
}
|
||
|
||
async function refreshOverview() {
|
||
let overview = { data_file: null, progress: null };
|
||
let strategies = { strategies: [] };
|
||
let training = { active: false, job: null, log_tail: [] };
|
||
|
||
try {
|
||
overview = await fetchJSON("/api/overview", { silent: true });
|
||
} catch (_) {}
|
||
|
||
try {
|
||
strategies = await fetchJSON("/api/strategies", { silent: true });
|
||
} catch (_) {}
|
||
|
||
try {
|
||
training = await fetchJSON("/api/training/status", { silent: true });
|
||
} catch (_) {}
|
||
|
||
if (overview.data_file) renderDataFileCard(overview.data_file);
|
||
updateFileProgress(overview.progress);
|
||
updateExportBtn(overview.progress, strategies.strategies);
|
||
updateTrainingBtns(overview.progress, training);
|
||
updateTrainingUI(training, overview.progress);
|
||
renderStrategies(strategies.strategies);
|
||
|
||
const sym = overview.progress?.symbol || selectedSymbol || training?.job?.symbol;
|
||
const trainingActive = !!training?.active;
|
||
if (lastTrainingActive && !trainingActive && sym) {
|
||
await applyBestStrategyForBacktest(sym, null);
|
||
}
|
||
lastTrainingActive = trainingActive;
|
||
|
||
if (sym && (training?.active || overview.progress)) {
|
||
await loadSymbolChart(sym, overview.progress);
|
||
}
|
||
|
||
await refreshDebugLogs();
|
||
refreshAiProviderStatus();
|
||
}
|
||
|
||
async function loadConfig() {
|
||
const health = await fetch(API + "/api/health").then((r) => r.json()).catch(() => ({}));
|
||
if (!health.version) {
|
||
await logClientError(
|
||
"后端版本过旧或未启动新版服务。请关闭旧进程后重新运行: python run_web.py",
|
||
{ health }
|
||
);
|
||
}
|
||
|
||
const cfg = await fetchJSON("/api/config");
|
||
debugMode = !!cfg.debug_mode;
|
||
$("debugModeCheck").checked = debugMode;
|
||
$("deviceMeta").textContent = `${cfg.train_steps} steps · batch ${cfg.batch_size} · ${cfg.device}`;
|
||
if (cfg.error_log) {
|
||
$("debugLogPaths").textContent = `本地: ${cfg.error_log}`;
|
||
}
|
||
if (cfg.data_file) renderDataFileCard(cfg.data_file);
|
||
if (cfg.strategy_file) renderStrategyFileCard(cfg.strategy_file);
|
||
applyBacktestCostDefaults(cfg);
|
||
await initAiPanel(cfg);
|
||
}
|
||
|
||
function applyBacktestCostDefaults(cfg) {
|
||
const cIn = $("btCommissionInput");
|
||
const sIn = $("btSlippageInput");
|
||
if (cIn && cfg.bt_commission_pct != null) cIn.value = Number(cfg.bt_commission_pct);
|
||
if (sIn && cfg.bt_slippage_pct != null) sIn.value = Number(cfg.bt_slippage_pct);
|
||
updateBtCostHint();
|
||
}
|
||
|
||
function readBacktestCosts() {
|
||
const cRaw = Number($("btCommissionInput")?.value);
|
||
const sRaw = Number($("btSlippageInput")?.value);
|
||
const commission = Number.isFinite(cRaw) && cRaw >= 0 ? cRaw : 0.02;
|
||
const slippage = Number.isFinite(sRaw) && sRaw >= 0 ? sRaw : 0.01;
|
||
return { commission_pct: commission, slippage_pct: slippage };
|
||
}
|
||
|
||
function updateBtCostHint() {
|
||
const hint = $("btCostSumHint");
|
||
if (!hint) return;
|
||
const { commission_pct, slippage_pct } = readBacktestCosts();
|
||
const fee = Number((commission_pct + slippage_pct).toFixed(4));
|
||
hint.textContent = `单边成本 ${fee}%`;
|
||
}
|
||
|
||
async function refreshAiProviderStatus() {
|
||
try {
|
||
const status = await fetchJSON("/api/ai/providers", { silent: true });
|
||
window.__aiProviderStatus = status;
|
||
} catch (_) {
|
||
/* keep previous snapshot */
|
||
}
|
||
updateAiChannelHint();
|
||
}
|
||
|
||
async function initAiPanel(cfg) {
|
||
const keyInput = $("aiApiKeyInput");
|
||
if (!keyInput) return;
|
||
|
||
if (cfg?.ai_api_key) keyInput.value = cfg.ai_api_key;
|
||
else if (cfg?.ai_provider === "openclaw" || cfg?.ai_provider === "openclaw_wb") {
|
||
keyInput.value = cfg.ai_provider;
|
||
}
|
||
|
||
await refreshAiProviderStatus();
|
||
if (!keyInput.dataset.aiStatusBound) {
|
||
keyInput.dataset.aiStatusBound = "1";
|
||
keyInput.addEventListener("input", () => {
|
||
updateAiChannelHint();
|
||
refreshAiProviderStatus();
|
||
});
|
||
}
|
||
}
|
||
|
||
function resolveAiFromKey(raw) {
|
||
const v = (raw || "").trim().toLowerCase();
|
||
// openclaw_wb 必须先于 openclaw,避免前缀误匹配
|
||
if (v === "openclaw_wb" || v.startsWith("openclaw_wb/")) {
|
||
return { provider: "openclaw_wb", apiKey: raw.trim(), isAlias: true };
|
||
}
|
||
if (v === "openclaw" || v.startsWith("openclaw/")) {
|
||
return { provider: "openclaw", apiKey: raw.trim(), isAlias: true };
|
||
}
|
||
return { provider: "deepseek", apiKey: (raw || "").trim(), isAlias: false };
|
||
}
|
||
|
||
function updateAiChannelHint() {
|
||
const hint = $("aiChannelHint");
|
||
const headHint = $("aiProviderHint");
|
||
const keyInput = $("aiApiKeyInput");
|
||
if (!hint || !keyInput) return;
|
||
|
||
const resolved = resolveAiFromKey(keyInput.value);
|
||
const status = window.__aiProviderStatus;
|
||
const row = (status?.providers || []).find((p) => p.id === resolved.provider);
|
||
|
||
if (resolved.provider === "deepseek") {
|
||
if (headHint) headHint.textContent = "DeepSeek · deepseek-v4-flash";
|
||
hint.textContent = "当前:DeepSeek(deepseek-v4-flash · https://api.deepseek.com)。";
|
||
} else if (resolved.provider === "openclaw") {
|
||
if (headHint) {
|
||
headHint.textContent = row?.available ? "openclaw (QClaw) · 已匹配" : "openclaw (QClaw) · 未就绪";
|
||
}
|
||
hint.textContent = row?.hint || "已匹配 openclaw:将自动使用本地 QClaw token。";
|
||
} else {
|
||
if (headHint) headHint.textContent = row?.available ? "openclaw_wb · 已匹配" : "openclaw_wb · 未就绪";
|
||
hint.textContent = row?.hint || "已匹配 openclaw_wb:将自动使用 WorkBuddy token。";
|
||
}
|
||
}
|
||
|
||
function openUnlimitedModal() {
|
||
const modal = $("aiUnlimitedModal");
|
||
if (modal) modal.hidden = false;
|
||
}
|
||
|
||
function closeUnlimitedModal() {
|
||
const modal = $("aiUnlimitedModal");
|
||
if (modal) modal.hidden = true;
|
||
}
|
||
|
||
async function runAiAnalyze() {
|
||
const btn = $("aiAnalyzeBtn");
|
||
const view = $("aiAnswerView");
|
||
const rawKey = $("aiApiKeyInput")?.value || "";
|
||
const resolved = resolveAiFromKey(rawKey);
|
||
if (!view) return;
|
||
|
||
if (resolved.provider === "deepseek" && !resolved.apiKey) {
|
||
view.className = "ai-answer error";
|
||
view.textContent = "请填写 DeepSeek API Key";
|
||
return;
|
||
}
|
||
|
||
await refreshAiProviderStatus();
|
||
|
||
if (btn) btn.disabled = true;
|
||
view.className = "ai-answer loading";
|
||
view.textContent = `正在通过 ${resolved.provider} 连接并流式分析…`;
|
||
|
||
let header = "";
|
||
let answer = "";
|
||
|
||
try {
|
||
const res = await fetch(API + "/api/ai/analyze-training", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
provider: resolved.provider,
|
||
api_key: resolved.apiKey,
|
||
symbol: selectedSymbol || null,
|
||
}),
|
||
});
|
||
if (!res.ok) {
|
||
const data = await res.json().catch(() => ({}));
|
||
throw new Error(formatApiError(data, res.status, "/api/ai/analyze-training"));
|
||
}
|
||
if (!res.body) throw new Error("浏览器不支持流式响应");
|
||
|
||
const reader = res.body.getReader();
|
||
const decoder = new TextDecoder("utf-8");
|
||
let buffer = "";
|
||
view.className = "ai-answer streaming";
|
||
view.textContent = "";
|
||
|
||
while (true) {
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
buffer += decoder.decode(value, { stream: true });
|
||
const chunks = buffer.split("\n\n");
|
||
buffer = chunks.pop() || "";
|
||
for (const block of chunks) {
|
||
const line = block
|
||
.split("\n")
|
||
.map((l) => l.trim())
|
||
.find((l) => l.startsWith("data:"));
|
||
if (!line) continue;
|
||
let event;
|
||
try {
|
||
event = JSON.parse(line.slice(5).trim());
|
||
} catch (_) {
|
||
continue;
|
||
}
|
||
if (event.type === "meta") {
|
||
header =
|
||
`[${event.label || event.provider || resolved.provider} · ${event.model || ""} · ${event.symbol || ""}${event.timeframe ? " " + event.timeframe : ""}]` +
|
||
(event.prior_count
|
||
? ` · 已带入前 ${event.prior_count} 次同品种同周期分析`
|
||
: " · 首次分析") +
|
||
`\n\n`;
|
||
view.textContent = header;
|
||
view.scrollTop = view.scrollHeight;
|
||
} else if (event.type === "delta") {
|
||
answer += event.text || "";
|
||
view.textContent = header + answer;
|
||
view.scrollTop = view.scrollHeight;
|
||
} else if (event.type === "error") {
|
||
throw new Error(event.message || "分析失败");
|
||
} else if (event.type === "done") {
|
||
answer = event.answer || answer;
|
||
view.className = "ai-answer";
|
||
view.textContent = header + (answer || "(无内容)");
|
||
}
|
||
}
|
||
}
|
||
if (!answer && view.className.includes("streaming")) {
|
||
throw new Error("流式分析中断,未收到完整回复");
|
||
}
|
||
view.className = "ai-answer";
|
||
} catch (e) {
|
||
view.className = "ai-answer error";
|
||
view.textContent = `分析失败: ${e.message}`;
|
||
} finally {
|
||
if (btn) btn.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function browseStrategyFile() {
|
||
try {
|
||
const res = await fetchJSON("/api/strategy-file/browse", { method: "POST" });
|
||
if (res.cancelled) return;
|
||
renderStrategyFileCard(res);
|
||
} catch (e) {
|
||
$("debugView")?.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||
}
|
||
}
|
||
|
||
async function applyBestStrategyForBacktest(symbol, strategyFile) {
|
||
if (strategyFile) {
|
||
renderStrategyFileCard(strategyFile);
|
||
return;
|
||
}
|
||
if (!symbol) return;
|
||
try {
|
||
const res = await fetchJSON(
|
||
`/api/strategy-file/sync-best?symbol=${encodeURIComponent(symbol)}`,
|
||
{ method: "POST" }
|
||
);
|
||
renderStrategyFileCard(res);
|
||
} catch (_) {
|
||
await loadBacktestStrategyContext();
|
||
}
|
||
}
|
||
|
||
async function loadBacktestStrategyContext() {
|
||
const sym = selectedStrategySymbol || selectedSymbol;
|
||
if (sym) {
|
||
await applyBestStrategyForBacktest(sym, null);
|
||
return;
|
||
}
|
||
try {
|
||
const cfg = await fetchJSON("/api/config");
|
||
if (cfg.strategy_file) renderStrategyFileCard(cfg.strategy_file);
|
||
} catch (_) {
|
||
/* ignore */
|
||
}
|
||
}
|
||
|
||
async function browseDataFile() {
|
||
try {
|
||
const res = await fetchJSON("/api/data-file/browse", { method: "POST" });
|
||
if (res.cancelled) return;
|
||
renderDataFileCard(res);
|
||
selectedSymbol = res.symbol;
|
||
await loadSymbolChart(res.symbol);
|
||
} catch (e) {
|
||
$("debugView").scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||
}
|
||
}
|
||
|
||
async function startTraining() {
|
||
if (!selectedDataFile) {
|
||
await logClientError("请先选择数据文件");
|
||
return;
|
||
}
|
||
try {
|
||
const res = await fetchJSON("/api/training/start", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ data_file: selectedDataFile, from_scratch: false }),
|
||
});
|
||
selectedSymbol = res.data_file?.symbol || res.job?.symbol;
|
||
renderDataFileCard(res.data_file);
|
||
await refreshOverview();
|
||
} catch (e) {
|
||
$("debugView").scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||
}
|
||
}
|
||
|
||
async function retrainFromScratch() {
|
||
if (!selectedDataFile) {
|
||
await logClientError("请先选择数据文件");
|
||
return;
|
||
}
|
||
const ok = window.confirm(
|
||
"重新训练会清除该品种的检查点,从第 0 步重新搜索。\n" +
|
||
"已有的更优策略会保留,只有挖到更高分才会覆盖。\n\n" +
|
||
"确定要重新训练吗?"
|
||
);
|
||
if (!ok) return;
|
||
try {
|
||
const res = await fetchJSON("/api/training/start", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ data_file: selectedDataFile, from_scratch: true }),
|
||
});
|
||
selectedSymbol = res.data_file?.symbol || res.job?.symbol;
|
||
renderDataFileCard(res.data_file);
|
||
await refreshOverview();
|
||
} catch (e) {
|
||
$("debugView").scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||
}
|
||
}
|
||
|
||
function updateExportBtn(progress, strategies) {
|
||
const sym = progress?.symbol || selectedSymbol;
|
||
const hasStrategy = progress?.has_strategy || (strategies || []).some((s) => s.symbol === sym);
|
||
const btn = $("exportBtn");
|
||
if (btn) btn.disabled = !sym || !hasStrategy;
|
||
}
|
||
|
||
function updateTrainingBtns(progress, training) {
|
||
const sym = progress?.symbol || selectedSymbol;
|
||
const active = training?.active;
|
||
const hasCheckpoint = Boolean(progress?.has_checkpoint);
|
||
const exportBtn = $("exportTrainingBtn");
|
||
const importBtn = $("importTrainingBtn");
|
||
|
||
let exportTitle = "打包 checkpoint、训练曲线与策略为 zip";
|
||
if (!sym) {
|
||
exportTitle = "请先选择数据文件";
|
||
} else if (active) {
|
||
exportTitle = "训练进行中,请停止后再导出";
|
||
} else if (!hasCheckpoint) {
|
||
exportTitle = "该品种尚无检查点:至少训练满 20 步后才会生成(每 20 步保存一次)";
|
||
}
|
||
|
||
if (exportBtn) {
|
||
exportBtn.disabled = !sym || !hasCheckpoint || !!active;
|
||
exportBtn.title = exportTitle;
|
||
}
|
||
if (importBtn) {
|
||
importBtn.disabled = !sym || !!active;
|
||
importBtn.title = active ? "训练进行中,请停止后再导入" : "上传 .zip 或 .pt,下次训练断点续训";
|
||
}
|
||
}
|
||
|
||
async function exportTraining() {
|
||
const sym = selectedSymbol;
|
||
if (!sym) {
|
||
await logClientError("请先选择数据文件");
|
||
return;
|
||
}
|
||
const path = `/api/training/${encodeURIComponent(sym)}/export`;
|
||
try {
|
||
const res = await fetch(API + path);
|
||
if (!res.ok) {
|
||
const data = await res.json().catch(() => ({}));
|
||
throw new Error(formatApiError(data, res.status, path));
|
||
}
|
||
const blob = await res.blob();
|
||
const disp = res.headers.get("Content-Disposition") || "";
|
||
const m = /filename="([^"]+)"/.exec(disp);
|
||
const filename = m ? m[1] : `training_${sym.replace(/\./g, "_")}.zip`;
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement("a");
|
||
a.href = url;
|
||
a.download = filename;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
a.remove();
|
||
URL.revokeObjectURL(url);
|
||
} catch (e) {
|
||
await logClientError(`导出训练失败: ${e.message}`);
|
||
$("debugView").scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||
}
|
||
}
|
||
|
||
function triggerImportTraining() {
|
||
const input = $("importTrainingFile");
|
||
if (input) {
|
||
input.value = "";
|
||
input.click();
|
||
}
|
||
}
|
||
|
||
async function handleImportTrainingFile(event) {
|
||
const input = event.target;
|
||
const file = input.files?.[0];
|
||
if (!file) return;
|
||
|
||
const sym = selectedSymbol;
|
||
if (!sym) {
|
||
await logClientError("请先选择数据文件");
|
||
return;
|
||
}
|
||
|
||
const form = new FormData();
|
||
form.append("file", file);
|
||
|
||
try {
|
||
const res = await fetch(`${API}/api/training/import?symbol=${encodeURIComponent(sym)}`, {
|
||
method: "POST",
|
||
body: form,
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) {
|
||
throw new Error(formatApiError(data, res.status, "/api/training/import"));
|
||
}
|
||
if (data.symbol && data.symbol !== sym) {
|
||
selectedSymbol = data.symbol;
|
||
}
|
||
clientErrors.push(`[${new Date().toLocaleString()}] ${data.message || "训练文件导入成功"}`);
|
||
if (clientErrors.length > 80) clientErrors = clientErrors.slice(-80);
|
||
renderDebugView();
|
||
await refreshOverview();
|
||
} catch (e) {
|
||
await logClientError(`导入训练失败: ${e.message}`);
|
||
$("debugView").scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||
} finally {
|
||
input.value = "";
|
||
}
|
||
}
|
||
|
||
function parseContentDispositionFilename(header) {
|
||
if (!header) return null;
|
||
const utf8 = /filename\*=UTF-8''([^;]+)/i.exec(header);
|
||
if (utf8) return decodeURIComponent(utf8[1]);
|
||
const plain = /filename="([^"]+)"/i.exec(header) || /filename=([^;]+)/i.exec(header);
|
||
return plain ? plain[1].trim() : null;
|
||
}
|
||
|
||
async function exportStrategy() {
|
||
const sym = selectedSymbol;
|
||
if (!sym) {
|
||
await logClientError("请先选择数据文件");
|
||
return;
|
||
}
|
||
const path = `/api/strategies/${encodeURIComponent(sym)}/export`;
|
||
try {
|
||
const res = await fetch(API + path);
|
||
if (!res.ok) {
|
||
const data = await res.json().catch(() => ({}));
|
||
throw new Error(formatApiError(data, res.status, path));
|
||
}
|
||
const blob = await res.blob();
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement("a");
|
||
a.href = url;
|
||
a.download =
|
||
parseContentDispositionFilename(res.headers.get("Content-Disposition")) ||
|
||
`strategy_${sym.replace(/\./g, "_")}.json`;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
a.remove();
|
||
URL.revokeObjectURL(url);
|
||
} catch (e) {
|
||
await logClientError(`导出策略失败: ${e.message}`);
|
||
$("debugView").scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||
}
|
||
}
|
||
|
||
async function stopTraining() {
|
||
try {
|
||
const res = await fetchJSON("/api/training/stop", { method: "POST" });
|
||
await refreshOverview();
|
||
const sym = res.training?.job?.symbol || selectedSymbol;
|
||
await applyBestStrategyForBacktest(sym, res.strategy_file);
|
||
} catch (e) {
|
||
$("debugView").scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
// 分页切换
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
function switchPage(page) {
|
||
if (page !== "train" && page !== "backtest" && page !== "realtime") return;
|
||
currentPage = page;
|
||
document.querySelectorAll(".stepper .step").forEach((s) => {
|
||
s.classList.toggle("active", s.dataset.page === page);
|
||
});
|
||
document.querySelectorAll(".page").forEach((p) => {
|
||
p.classList.toggle("active", p.id === `page-${page}`);
|
||
});
|
||
if (page === "backtest") {
|
||
loadBacktestStrategyContext();
|
||
refreshBacktest();
|
||
} else if (page === "realtime") {
|
||
initRealtimeOnce();
|
||
refreshRealtime();
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
// 回测:格式化辅助
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
function fmtPct(v, digits = 2) {
|
||
if (v == null || Number.isNaN(v)) return "—";
|
||
return (v >= 0 ? "+" : "") + (v * 100).toFixed(digits) + "%";
|
||
}
|
||
function fmtSigned(v, digits = 3) {
|
||
if (v == null || Number.isNaN(v)) return "—";
|
||
return (v >= 0 ? "+" : "") + Number(v).toFixed(digits);
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
// 回测:状态轮询 + UI 更新
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
async function refreshBacktest() {
|
||
let st;
|
||
try {
|
||
st = await fetchJSON("/api/backtest/status", { silent: true });
|
||
} catch (_) {
|
||
return;
|
||
}
|
||
btActive = !!st.active;
|
||
const job = st.job;
|
||
const state = job?.state || "idle";
|
||
|
||
// 按钮
|
||
const stopBtn = $("btStopBtn");
|
||
updateBtStartBtn();
|
||
if (stopBtn) stopBtn.disabled = !btActive;
|
||
|
||
// 缓存刷新键:用最近一次任务的结束/开始时间
|
||
btBuster = job?.finished_at || job?.started_at || btBuster;
|
||
|
||
// 日志
|
||
const logView = $("btLogView");
|
||
const logText = (st.log_tail || []).join("\n") || "等待任务…";
|
||
if (logView) {
|
||
const atBottom = isViewAtBottom(logView);
|
||
logView.textContent = logText;
|
||
if (atBottom) logView.scrollTop = logView.scrollHeight;
|
||
}
|
||
if ($("btLogHint")) $("btLogHint").textContent = job?.log_path || "—";
|
||
|
||
// 阶段进度条
|
||
updateBacktestPhase(st, state);
|
||
|
||
if (state === "failed") {
|
||
const alertKey = `${job?.log_path || ""}|${job?.finished_at || ""}|${job?.exit_code ?? ""}`;
|
||
if (alertKey && alertKey !== btLastAlertKey) {
|
||
btLastAlertKey = alertKey;
|
||
const errLine = job?.error ? `\n错误: ${job.error}` : "";
|
||
showErrorPopup(
|
||
"回测失败",
|
||
`退出码: ${job?.exit_code ?? "?"}${errLine}\n日志: ${job?.log_path || "—"}\n\n${logText}`
|
||
);
|
||
}
|
||
}
|
||
|
||
// 结果报告(非运行态时刷新,运行态保留上次结果)
|
||
if (!btActive) {
|
||
await refreshBacktestReport();
|
||
}
|
||
}
|
||
|
||
const BT_STATE_LABEL = {
|
||
running: "回测中",
|
||
completed: "已完成",
|
||
failed: "失败",
|
||
stopped: "已停止",
|
||
idle: "待机",
|
||
};
|
||
|
||
function updateBacktestPhase(st, state) {
|
||
const fill = $("btPhaseFill");
|
||
const label = $("btPhaseLabel");
|
||
if (!fill || !label) return;
|
||
|
||
const total = st.phase_total || 7;
|
||
const idx = st.phase_index || 0;
|
||
|
||
let pct;
|
||
if (btActive) {
|
||
pct = Math.min(96, Math.round(((idx + 1) / total) * 100));
|
||
label.textContent = `${st.phase_label || "回测中"}…`;
|
||
fill.classList.add("animate");
|
||
} else if (state === "completed") {
|
||
pct = 100;
|
||
label.textContent = "完成";
|
||
fill.classList.remove("animate");
|
||
} else if (state === "failed" || state === "stopped") {
|
||
pct = Math.min(96, Math.round(((idx + 1) / total) * 100));
|
||
label.textContent = BT_STATE_LABEL[state];
|
||
fill.classList.remove("animate");
|
||
} else {
|
||
pct = 0;
|
||
label.textContent = "待机";
|
||
fill.classList.remove("animate");
|
||
}
|
||
fill.style.width = pct + "%";
|
||
}
|
||
|
||
async function refreshBacktestReport() {
|
||
let data;
|
||
const sym = selectedStrategySymbol || selectedSymbol;
|
||
const url = sym
|
||
? `/api/backtest/report?symbol=${encodeURIComponent(sym)}`
|
||
: "/api/backtest/report";
|
||
try {
|
||
data = await fetchJSON(url, { silent: true });
|
||
} catch (_) {
|
||
return;
|
||
}
|
||
if (!data.available || !data.report) {
|
||
if ($("btPortfolioHint")) $("btPortfolioHint").textContent = "尚未运行回测";
|
||
lastEquityData = null;
|
||
btPortfolioSig = "";
|
||
renderEquity(null);
|
||
return;
|
||
}
|
||
// 先取资金曲线(写入 lastEquityData),再渲染绩效卡,让 sparkline 用上真实数据
|
||
await refreshEquityCurve();
|
||
renderPortfolio(data.report);
|
||
renderBacktestTable(data.report.symbols || {});
|
||
}
|
||
|
||
async function refreshEquityCurve() {
|
||
const sym = selectedStrategySymbol || selectedSymbol;
|
||
const url = sym
|
||
? `/api/backtest/equity?symbol=${encodeURIComponent(sym)}`
|
||
: "/api/backtest/equity";
|
||
try {
|
||
const data = await fetchJSON(url, { silent: true });
|
||
lastEquityData = data?.available ? data.data : null;
|
||
renderEquity(data);
|
||
} catch (_) {
|
||
lastEquityData = null;
|
||
renderEquity(null);
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
// 迷你 sparkline + 数字滚动动画(终端仪表盘质感)
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
const METRIC_FMT = {
|
||
pct: (v) => (v >= 0 ? "+" : "") + (v * 100).toFixed(2) + "%",
|
||
signed: (v) => (v >= 0 ? "+" : "") + v.toFixed(3),
|
||
ratio: (v) => v.toFixed(3),
|
||
int: (v) => Math.round(v).toLocaleString(),
|
||
winrate: (v) => (v * 100).toFixed(1) + "%",
|
||
strength: (v) => Math.round(v * 100) + "%",
|
||
};
|
||
|
||
function prefersReducedMotion() {
|
||
return !!(window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches);
|
||
}
|
||
|
||
// 短促 count-up(≈420ms, easeOutCubic),克制不浮夸
|
||
function animateCount(el, to, fmt) {
|
||
const fn = METRIC_FMT[fmt] || ((v) => String(v));
|
||
if (!Number.isFinite(to)) {
|
||
el.textContent = "—";
|
||
return;
|
||
}
|
||
if (prefersReducedMotion()) {
|
||
el.textContent = fn(to);
|
||
return;
|
||
}
|
||
const dur = 420;
|
||
const t0 = performance.now();
|
||
function frame(now) {
|
||
const p = Math.min(1, (now - t0) / dur);
|
||
const e = 1 - Math.pow(1 - p, 3); // easeOutCubic
|
||
el.textContent = fn(to * e);
|
||
if (p < 1) requestAnimationFrame(frame);
|
||
else el.textContent = fn(to);
|
||
}
|
||
requestAnimationFrame(frame);
|
||
}
|
||
|
||
function runCountUp(root) {
|
||
if (!root) return;
|
||
root.querySelectorAll("[data-count]").forEach((el) => {
|
||
animateCount(el, parseFloat(el.dataset.count), el.dataset.fmt || "");
|
||
});
|
||
}
|
||
|
||
// 均匀降采样为 <= target 个有限点
|
||
function downsampleSeries(arr, target) {
|
||
const clean = (arr || [])
|
||
.map(Number)
|
||
.filter((v) => Number.isFinite(v));
|
||
if (clean.length <= target) return clean;
|
||
const out = [];
|
||
const step = (clean.length - 1) / (target - 1);
|
||
for (let i = 0; i < target; i++) out.push(clean[Math.round(i * step)]);
|
||
return out;
|
||
}
|
||
|
||
// 生成极小趋势微线(内联 SVG,轻量、清晰)
|
||
function sparklineSVG(values, { color = "#5eead4", fillRGB = null, w = 74, h = 22 } = {}) {
|
||
const v = downsampleSeries(values, 56);
|
||
if (v.length < 2) return "";
|
||
const min = Math.min(...v);
|
||
const max = Math.max(...v);
|
||
const range = max - min || 1;
|
||
const n = v.length;
|
||
const x = (i) => (i / (n - 1)) * w;
|
||
const y = (val) => h - 2 - ((val - min) / range) * (h - 4);
|
||
const line = "M" + v.map((val, i) => `${x(i).toFixed(1)} ${y(val).toFixed(1)}`).join(" L ");
|
||
const area = fillRGB
|
||
? `<path d="${line} L ${w} ${h} L 0 ${h} Z" fill="rgba(${fillRGB},0.14)" stroke="none"/>`
|
||
: "";
|
||
const dot = `<circle cx="${x(n - 1).toFixed(1)}" cy="${y(v[n - 1]).toFixed(1)}" r="1.6" fill="${color}"/>`;
|
||
return `<svg class="spark-svg" viewBox="0 0 ${w} ${h}" preserveAspectRatio="none" aria-hidden="true">${area}<path d="${line}" fill="none" stroke="${color}" stroke-width="1.4" stroke-linejoin="round" stroke-linecap="round"/>${dot}</svg>`;
|
||
}
|
||
|
||
// 取当前主资金曲线序列(组合优先,否则第一个品种)
|
||
function mainEquitySeries() {
|
||
const d = lastEquityData;
|
||
if (!d) return null;
|
||
if (d.portfolio) return d.portfolio;
|
||
const syms = d.symbols || {};
|
||
const names = Object.keys(syms);
|
||
return names.length ? syms[names[0]] : null;
|
||
}
|
||
|
||
function renderPortfolio(report) {
|
||
const grid = $("btPortfolioGrid");
|
||
if (!grid) return;
|
||
const p = report.portfolio || {};
|
||
const focus = report.focus_symbol || Object.keys(report.symbols || {})[0] || "";
|
||
const symData = focus ? (report.symbols || {})[focus] : null;
|
||
|
||
if (!Object.keys(p).length) {
|
||
grid.innerHTML = '<div class="metric-empty">回测结果无绩效数据</div>';
|
||
btPortfolioSig = "";
|
||
return;
|
||
}
|
||
|
||
const plNum = Number(symData?.profit_loss_ratio ?? p.profit_loss_ratio);
|
||
const nTrades = symData?.n_trades ?? p.n_trades;
|
||
const winRate = symData?.win_rate;
|
||
|
||
// sparkline 数据源:主资金曲线 + 滚动夏普
|
||
const eq = mainEquitySeries();
|
||
const posColor = p.total_return >= 0 ? "#4ade80" : "#f87171";
|
||
const posRGB = p.total_return >= 0 ? "74, 222, 128" : "248, 113, 113";
|
||
const equitySpark = eq ? sparklineSVG(eq.equity, { color: posColor, fillRGB: posRGB }) : "";
|
||
const rollSpark = eq ? sparklineSVG(eq.rolling_sharpe, { color: "#5eead4", fillRGB: "94, 234, 212" }) : "";
|
||
|
||
const cards = [
|
||
{ label: "总收益", raw: p.total_return, fmt: "pct", cls: p.total_return >= 0 ? "pos" : "neg", spark: equitySpark },
|
||
{ label: "Sharpe", raw: p.sharpe, fmt: "signed", cls: "accent", spark: rollSpark },
|
||
{ label: "Sortino", raw: p.sortino, fmt: "signed", cls: "accent", spark: rollSpark },
|
||
{ label: "盈亏比", raw: Number.isFinite(plNum) ? plNum : null, fmt: "ratio", cls: Number.isFinite(plNum) ? "accent" : "" },
|
||
{ label: "交易数", raw: Number.isFinite(Number(nTrades)) ? Number(nTrades) : null, fmt: "int", cls: "" },
|
||
{ label: "胜率", raw: winRate != null ? Number(winRate) : null, fmt: "winrate", cls: "" },
|
||
];
|
||
|
||
// 签名守卫:数值/焦点/资金曲线未变则不重建,避免每次轮询重播动画
|
||
const sig = [focus, btEquitySig, ...cards.map((c) => c.raw)].join("|");
|
||
if (sig === btPortfolioSig) {
|
||
if ($("btPortfolioHint")) $("btPortfolioHint").textContent = focus ? `${focus} 回测绩效` : "回测绩效";
|
||
return;
|
||
}
|
||
btPortfolioSig = sig;
|
||
|
||
grid.innerHTML = cards
|
||
.map((c) => {
|
||
const cardCls = c.cls === "pos" || c.cls === "neg" ? c.cls : "";
|
||
const finite = c.raw != null && Number.isFinite(c.raw);
|
||
const finalText = finite ? METRIC_FMT[c.fmt](c.raw) : "—";
|
||
const countAttr = finite ? ` data-count="${c.raw}" data-fmt="${c.fmt}"` : "";
|
||
const spark = c.spark ? `<div class="metric-spark">${c.spark}</div>` : "";
|
||
return `
|
||
<div class="metric-card ${cardCls}">
|
||
<div class="metric-label">${c.label}</div>
|
||
<div class="metric-value ${c.cls}"${countAttr}>${finalText}</div>
|
||
${spark}
|
||
</div>`;
|
||
})
|
||
.join("");
|
||
|
||
runCountUp(grid);
|
||
|
||
if ($("btPortfolioHint")) {
|
||
$("btPortfolioHint").textContent = focus ? `${focus} 回测绩效` : "回测绩效";
|
||
}
|
||
}
|
||
|
||
function renderBacktestTable(symbols) {
|
||
const tbody = $("btTableBody");
|
||
if (!tbody) return;
|
||
const rows = Object.entries(symbols);
|
||
if (!rows.length) {
|
||
tbody.innerHTML = '<tr class="empty-row"><td colspan="7">暂无回测结果</td></tr>';
|
||
if ($("btTableHint")) $("btTableHint").textContent = "—";
|
||
return;
|
||
}
|
||
if ($("btTableHint")) $("btTableHint").textContent = rows.length === 1 ? rows[0][0] : `${rows.length} 个品种`;
|
||
tbody.innerHTML = rows
|
||
.map(([sym, d]) => {
|
||
const retCls = (d.total_return || 0) >= 0 ? "pos" : "neg";
|
||
const shCls = (d.sharpe || 0) >= 0 ? "pos" : "neg";
|
||
return `
|
||
<tr>
|
||
<td class="sym-cell">${sym}</td>
|
||
<td class="${retCls}">${fmtPct(d.total_return)}</td>
|
||
<td class="${shCls}">${fmtSigned(d.sharpe)}</td>
|
||
<td>${fmtSigned(d.sortino)}</td>
|
||
<td>${Number.isFinite(Number(d.profit_loss_ratio)) ? Number(d.profit_loss_ratio).toFixed(3) : "—"}</td>
|
||
<td>${d.n_trades ?? "—"}</td>
|
||
<td>${d.win_rate != null ? (d.win_rate * 100).toFixed(1) + "%" : "—"}</td>
|
||
</tr>`;
|
||
})
|
||
.join("");
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
// 交互式资金曲线(HTML / Chart.js)
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
let equityChart = null;
|
||
let rollingChart = null;
|
||
let btEquitySig = "";
|
||
|
||
const EQUITY_COLORS = [
|
||
{ hex: "#5eead4", rgb: "94, 234, 212" },
|
||
{ hex: "#38bdf8", rgb: "56, 189, 248" },
|
||
{ hex: "#818cf8", rgb: "129, 140, 248" },
|
||
{ hex: "#fbbf24", rgb: "251, 191, 36" },
|
||
{ hex: "#f472b6", rgb: "244, 114, 182" },
|
||
{ hex: "#a3e635", rgb: "163, 230, 53" },
|
||
];
|
||
|
||
function verticalGradient(chart, rgb, topAlpha, bottomAlpha) {
|
||
const { ctx, chartArea } = chart;
|
||
if (!chartArea) return `rgba(${rgb}, ${topAlpha})`;
|
||
const g = ctx.createLinearGradient(0, chartArea.top, 0, chartArea.bottom);
|
||
g.addColorStop(0, `rgba(${rgb}, ${topAlpha})`);
|
||
g.addColorStop(0.62, `rgba(${rgb}, ${(topAlpha + bottomAlpha) / 4})`);
|
||
g.addColorStop(1, `rgba(${rgb}, ${bottomAlpha})`);
|
||
return g;
|
||
}
|
||
|
||
const EQUITY_TOOLTIP = {
|
||
backgroundColor: "rgba(8, 12, 20, 0.94)",
|
||
borderColor: "rgba(94, 234, 212, 0.35)",
|
||
borderWidth: 1,
|
||
titleColor: "#e8edf4",
|
||
bodyColor: "#a9bccf",
|
||
titleFont: { family: "'JetBrains Mono'", size: 11 },
|
||
bodyFont: { family: "'JetBrains Mono'", size: 11 },
|
||
padding: 10,
|
||
cornerRadius: 8,
|
||
usePointStyle: true,
|
||
};
|
||
|
||
const EQUITY_OPTIONS = {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
interaction: { mode: "index", intersect: false },
|
||
animation: { duration: 500, easing: "easeOutQuart" },
|
||
plugins: {
|
||
legend: {
|
||
display: true,
|
||
labels: {
|
||
color: "#a9bccf",
|
||
usePointStyle: true,
|
||
pointStyle: "circle",
|
||
boxWidth: 8,
|
||
boxHeight: 8,
|
||
padding: 14,
|
||
font: { family: "'DM Sans'", size: 12, weight: "600" },
|
||
},
|
||
},
|
||
tooltip: {
|
||
...EQUITY_TOOLTIP,
|
||
callbacks: {
|
||
label: (c) => ` ${c.dataset.label}: ${Number(c.parsed.y).toFixed(4)}`,
|
||
},
|
||
},
|
||
},
|
||
scales: {
|
||
x: {
|
||
ticks: { color: "#6b7d92", maxTicksLimit: 8, maxRotation: 0, font: { family: "'JetBrains Mono'", size: 10 } },
|
||
grid: { color: "rgba(120,190,235,0.05)" },
|
||
border: { color: "rgba(120,190,235,0.12)" },
|
||
},
|
||
y: {
|
||
ticks: { color: "#6b7d92", font: { family: "'JetBrains Mono'", size: 10 } },
|
||
grid: { color: "rgba(120,190,235,0.05)" },
|
||
border: { color: "rgba(120,190,235,0.12)" },
|
||
},
|
||
},
|
||
};
|
||
|
||
const ROLLING_OPTIONS = {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
interaction: { mode: "index", intersect: false },
|
||
animation: { duration: 500, easing: "easeOutQuart" },
|
||
spanGaps: false,
|
||
plugins: {
|
||
legend: { display: false },
|
||
tooltip: {
|
||
...EQUITY_TOOLTIP,
|
||
borderColor: "rgba(251, 191, 36, 0.4)",
|
||
callbacks: {
|
||
label: (c) => {
|
||
const v = c.parsed.y;
|
||
if (v == null || Number.isNaN(v)) return " 滚动夏普: —";
|
||
return ` 滚动夏普: ${Number(v).toFixed(3)}`;
|
||
},
|
||
},
|
||
},
|
||
},
|
||
scales: {
|
||
x: {
|
||
ticks: { color: "#6b7d92", maxTicksLimit: 8, maxRotation: 0, font: { family: "'JetBrains Mono'", size: 10 } },
|
||
grid: { color: "rgba(120,190,235,0.05)" },
|
||
border: { color: "rgba(120,190,235,0.12)" },
|
||
},
|
||
y: {
|
||
ticks: { color: "#6b7d92", font: { family: "'JetBrains Mono'", size: 10 } },
|
||
grid: { color: "rgba(251,191,36,0.06)" },
|
||
border: { color: "rgba(251,191,36,0.18)" },
|
||
},
|
||
},
|
||
};
|
||
|
||
function destroyEquityCharts() {
|
||
if (equityChart) { equityChart.destroy(); equityChart = null; }
|
||
if (rollingChart) { rollingChart.destroy(); rollingChart = null; }
|
||
}
|
||
|
||
function renderEquityStats(name, series) {
|
||
const el = $("btEquityStats");
|
||
if (!el) return;
|
||
const pl = series.profit_loss_ratio;
|
||
const plText = Number.isFinite(Number(pl)) ? Number(pl).toFixed(3) : "—";
|
||
const roll = series.rolling_sharpe || [];
|
||
let lastRoll = null;
|
||
for (let i = roll.length - 1; i >= 0; i--) {
|
||
const v = Number(roll[i]);
|
||
if (Number.isFinite(v)) {
|
||
lastRoll = v;
|
||
break;
|
||
}
|
||
}
|
||
const plNum = Number(pl);
|
||
const cards = [
|
||
{ label: "总收益", raw: series.total_return, fmt: "pct", cls: series.total_return >= 0 ? "pos" : "neg" },
|
||
{ label: "夏普", raw: series.sharpe, fmt: "signed", cls: "accent" },
|
||
{ label: "索提诺", raw: series.sortino, fmt: "signed", cls: "accent" },
|
||
{ label: "盈亏比", raw: Number.isFinite(plNum) ? plNum : null, fmt: "ratio", cls: "accent" },
|
||
{
|
||
label: "最新滚动夏普",
|
||
raw: lastRoll,
|
||
fmt: "signed",
|
||
cls: lastRoll == null ? "" : lastRoll >= 0 ? "accent" : "neg",
|
||
},
|
||
];
|
||
el.innerHTML =
|
||
`<span class="equity-stat-name">${name}</span>` +
|
||
cards
|
||
.map((c) => {
|
||
const finite = c.raw != null && Number.isFinite(c.raw);
|
||
const finalText = finite ? METRIC_FMT[c.fmt](c.raw) : "—";
|
||
const countAttr = finite ? ` data-count="${c.raw}" data-fmt="${c.fmt}"` : "";
|
||
return `
|
||
<div class="equity-stat">
|
||
<span class="equity-stat-label">${c.label}</span>
|
||
<span class="equity-stat-value ${c.cls}"${countAttr}>${finalText}</span>
|
||
</div>`;
|
||
})
|
||
.join("");
|
||
runCountUp(el);
|
||
}
|
||
|
||
function buildEquityChart(labels, symbols, portfolio) {
|
||
const canvas = $("btEquityChart");
|
||
if (!canvas) return;
|
||
const symNames = Object.keys(symbols);
|
||
const multi = symNames.length > 1;
|
||
const datasets = symNames.map((s, i) => {
|
||
const col = EQUITY_COLORS[i % EQUITY_COLORS.length];
|
||
return {
|
||
label: s,
|
||
data: symbols[s].equity,
|
||
borderColor: col.hex,
|
||
borderWidth: multi ? 1.5 : 2.2,
|
||
tension: 0.25,
|
||
pointRadius: 0,
|
||
pointHoverRadius: 4,
|
||
pointHoverBackgroundColor: col.hex,
|
||
pointHoverBorderColor: "#05070d",
|
||
fill: !multi,
|
||
backgroundColor: (ctx) => verticalGradient(ctx.chart, col.rgb, 0.3, 0),
|
||
};
|
||
});
|
||
if (portfolio) {
|
||
datasets.push({
|
||
label: "等权组合",
|
||
data: portfolio.equity,
|
||
borderColor: "#e8edf4",
|
||
borderWidth: 2.4,
|
||
tension: 0.25,
|
||
pointRadius: 0,
|
||
pointHoverRadius: 4,
|
||
pointHoverBackgroundColor: "#e8edf4",
|
||
pointHoverBorderColor: "#05070d",
|
||
fill: true,
|
||
backgroundColor: (ctx) => verticalGradient(ctx.chart, "232, 237, 244", 0.16, 0),
|
||
});
|
||
}
|
||
if (equityChart) equityChart.destroy();
|
||
equityChart = new Chart(canvas.getContext("2d"), {
|
||
type: "line",
|
||
data: { labels, datasets },
|
||
options: EQUITY_OPTIONS,
|
||
});
|
||
}
|
||
|
||
function buildRollingChart(labels, series, windowBars) {
|
||
const canvas = $("btRollingChart");
|
||
if (!canvas) return;
|
||
if (rollingChart) rollingChart.destroy();
|
||
const data = series.rolling_sharpe || [];
|
||
const labelEl = $("btRollingLabel");
|
||
if (labelEl) {
|
||
labelEl.textContent = windowBars
|
||
? `滚动夏普 · ${windowBars} bars`
|
||
: "滚动夏普 · Rolling Sharpe";
|
||
}
|
||
rollingChart = new Chart(canvas.getContext("2d"), {
|
||
type: "line",
|
||
data: {
|
||
labels,
|
||
datasets: [
|
||
{
|
||
label: "滚动夏普",
|
||
data,
|
||
borderColor: "#fbbf24",
|
||
borderWidth: 1.5,
|
||
tension: 0.2,
|
||
pointRadius: 0,
|
||
pointHoverRadius: 4,
|
||
pointHoverBackgroundColor: "#fbbf24",
|
||
pointHoverBorderColor: "#05070d",
|
||
spanGaps: false,
|
||
fill: {
|
||
target: "origin",
|
||
above: "rgba(251, 191, 36, 0.16)",
|
||
below: "rgba(248, 113, 113, 0.16)",
|
||
},
|
||
},
|
||
],
|
||
},
|
||
options: ROLLING_OPTIONS,
|
||
});
|
||
}
|
||
|
||
function renderEquity(resp) {
|
||
const live = $("btEquityLive");
|
||
const empty = $("btEquityEmpty");
|
||
const data = resp?.data;
|
||
const symbols = data?.symbols || {};
|
||
const symNames = Object.keys(symbols);
|
||
|
||
if (!resp?.available || !symNames.length) {
|
||
if (live) live.hidden = true;
|
||
if (empty) empty.hidden = false;
|
||
destroyEquityCharts();
|
||
btEquitySig = "";
|
||
return;
|
||
}
|
||
|
||
const focus = resp.focus_symbol;
|
||
const sig = [focus, data.total_bars, data.n_points, data.rolling_window, symNames.join(",")].join("|") + "|" + btBuster;
|
||
if (sig === btEquitySig && equityChart) return; // 无变化,避免重建闪烁
|
||
btEquitySig = sig;
|
||
|
||
if (live) live.hidden = false;
|
||
if (empty) empty.hidden = true;
|
||
|
||
const portfolio = data.portfolio || null;
|
||
let mainName, mainSeries;
|
||
if (portfolio) {
|
||
mainName = "等权组合";
|
||
mainSeries = portfolio;
|
||
} else {
|
||
const key = focus && symbols[focus] ? focus : symNames[0];
|
||
mainName = key;
|
||
mainSeries = symbols[key];
|
||
}
|
||
|
||
renderEquityStats(mainName, mainSeries);
|
||
buildEquityChart(data.labels, symbols, portfolio);
|
||
buildRollingChart(data.labels, mainSeries, data.rolling_window);
|
||
|
||
if ($("btChartsHint")) {
|
||
$("btChartsHint").textContent = `${mainName} · 交互式资金曲线 · 悬停查看数值`;
|
||
}
|
||
}
|
||
|
||
async function startBacktest() {
|
||
if (!selectedStrategyFile) {
|
||
await logClientError("请先选择策略文件");
|
||
return;
|
||
}
|
||
const startBtn = $("btStartBtn");
|
||
if (startBtn) startBtn.disabled = true;
|
||
try {
|
||
const costs = readBacktestCosts();
|
||
const res = await fetchJSON("/api/backtest/start", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
strategy_file: selectedStrategyFile,
|
||
commission_pct: costs.commission_pct,
|
||
slippage_pct: costs.slippage_pct,
|
||
}),
|
||
});
|
||
if (res.strategy_file) renderStrategyFileCard(res.strategy_file);
|
||
await refreshBacktest();
|
||
} catch (e) {
|
||
if ($("btLogHint")) $("btLogHint").textContent = e.message;
|
||
updateBtStartBtn();
|
||
}
|
||
}
|
||
|
||
async function stopBacktest() {
|
||
try {
|
||
await fetchJSON("/api/backtest/stop", { method: "POST" });
|
||
await refreshBacktest();
|
||
} catch (e) {
|
||
if ($("btLogHint")) $("btLogHint").textContent = e.message;
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
// 实时行情分析(信号雷达)
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
let rtInited = false;
|
||
let rtEngineRunning = false;
|
||
let rtSources = [];
|
||
let rtSourceById = {};
|
||
let rtImportedStrategy = null; // {path, name}
|
||
let rtGridSig = "";
|
||
let rtServerSkew = 0; // server_time - local_now(秒)
|
||
let rtCountdownTimer = null;
|
||
let rtTvBlockedShownAt = 0;
|
||
let rtTvWikiUrl = "https://my.feishu.cn/wiki/FuqnwkPwdiCLhQkPloKc7r1lntg";
|
||
const RT_TV_BLOCKED_MSG =
|
||
"当前设备无法连接 TradingView 数据服务,将无法获取以下 K 线数据:\n" +
|
||
" · A 股(上证 SSE、深证 SZSE)\n" +
|
||
" · 港股(HKEX)\n" +
|
||
" · 美股及指数(NYSE、NASDAQ、SP)\n" +
|
||
" · 外汇、贵金属、商品期货\n\n" +
|
||
"解决方案:\n" +
|
||
" · 把你的VPN工具设成全局,并开启TUN(虚拟网卡)模式,如果还不行:\n" +
|
||
" · 使用云服务器部署本程序(推荐)—— 云服务器可正常连接 TradingView\n" +
|
||
" · 或切换回 MT5 数据源,仅使用 MT5 提供的品种数据";
|
||
const RT_TV_BLOCKED_CODE = "TV_CONNECTIVITY_BLOCKED";
|
||
|
||
const RT_DIR = {
|
||
LONG: { label: "↑ 预期上涨", cls: "rt-long", color: "#4ade80" },
|
||
SHORT: { label: "↓ 预期下跌", cls: "rt-short", color: "#f87171" },
|
||
FLAT: { label: "— 先观望", cls: "rt-flat", color: "#7a8a9e" },
|
||
};
|
||
const RT_STATE_LABEL = {
|
||
pending: "等待首次计算",
|
||
ok: "运行中",
|
||
insufficient: "历史不足",
|
||
error: "错误",
|
||
};
|
||
|
||
/** 把 0~1 强度翻成「把握」白话 */
|
||
function rtSizePlain(strength, direction) {
|
||
if (direction === "FLAT" || direction == null) {
|
||
return { size: "没把握" };
|
||
}
|
||
const s = Math.max(0, Math.min(1, Number(strength) || 0));
|
||
let size;
|
||
if (s < 0.2) size = "一点把握";
|
||
else if (s < 0.4) size = "把握不大";
|
||
else if (s < 0.6) size = "一半把握";
|
||
else if (s < 0.8) size = "比较有把握";
|
||
else size = "很有把握";
|
||
return { size };
|
||
}
|
||
|
||
function escHtml(s) {
|
||
return String(s == null ? "" : s).replace(/[&<>"]/g, (c) =>
|
||
({ "&": "&", "<": "<", ">": ">", '"': """ }[c])
|
||
);
|
||
}
|
||
|
||
function rtClock(ts) {
|
||
if (!ts) return "—";
|
||
return new Date(ts * 1000).toLocaleTimeString();
|
||
}
|
||
|
||
function rtNowSec() {
|
||
return Date.now() / 1000 + rtServerSkew;
|
||
}
|
||
|
||
function rtFmtCountdown(sec) {
|
||
const s = Math.max(0, Math.floor(sec));
|
||
if (s < 60) return `${s}秒`;
|
||
const m = Math.floor(s / 60);
|
||
const rs = s % 60;
|
||
if (m < 60) return `${m}分${String(rs).padStart(2, "0")}秒`;
|
||
const h = Math.floor(m / 60);
|
||
const rm = m % 60;
|
||
if (h < 48) return `${h}小时${rm}分`;
|
||
const d = Math.floor(h / 24);
|
||
return `${d}天${h % 24}小时`;
|
||
}
|
||
|
||
function ensureRtCountdownTimer() {
|
||
if (rtCountdownTimer) return;
|
||
rtCountdownTimer = setInterval(tickRtCountdowns, 1000);
|
||
}
|
||
|
||
function tickRtCountdowns() {
|
||
document.querySelectorAll(".rt-countdown").forEach((el) => {
|
||
if (el.dataset.session === "closed") {
|
||
el.textContent = "休市中";
|
||
return;
|
||
}
|
||
const nxt = Number(el.dataset.nextClose);
|
||
if (!Number.isFinite(nxt) || nxt <= 0) {
|
||
el.textContent = "距离下次判断 —";
|
||
return;
|
||
}
|
||
const left = nxt - rtNowSec();
|
||
el.textContent = left <= 0 ? "即将重新判断…" : `距离下次判断 ${rtFmtCountdown(left)}`;
|
||
});
|
||
const hintCd = $("rtNextHint");
|
||
if (hintCd) {
|
||
if (hintCd.dataset.session === "closed") {
|
||
hintCd.textContent = "休市中";
|
||
return;
|
||
}
|
||
if (hintCd.dataset.nextClose) {
|
||
const nxt = Number(hintCd.dataset.nextClose);
|
||
if (Number.isFinite(nxt) && nxt > 0) {
|
||
const left = nxt - rtNowSec();
|
||
hintCd.textContent =
|
||
left <= 0 ? "即将重新判断" : `距离下次判断 ${rtFmtCountdown(left)}`;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
async function initRealtimeOnce() {
|
||
if (rtInited) return;
|
||
rtInited = true;
|
||
try {
|
||
const data = await fetchJSON("/api/realtime/sources");
|
||
rtSources = data.sources || [];
|
||
rtSourceById = {};
|
||
rtSources.forEach((s) => (rtSourceById[s.id] = s));
|
||
const sel = $("rtSourceSelect");
|
||
if (sel) {
|
||
sel.innerHTML = rtSources
|
||
.map((s) => `<option value="${s.id}">${escHtml(s.label)}${s.available ? "" : " · 未就绪"}</option>`)
|
||
.join("");
|
||
// 默认选第一个可用数据源
|
||
if (rtSources.length) sel.value = rtSources[0].id;
|
||
}
|
||
if (data.min_exposure != null && $("rtThresholdHint")) {
|
||
$("rtThresholdHint").textContent = `无信号阈值 |tanh(因子)| < ${data.min_exposure}`;
|
||
}
|
||
onRtSourceChange();
|
||
} catch (e) {
|
||
await logClientError("加载数据源失败: " + e.message);
|
||
}
|
||
await loadRtStrategies();
|
||
await loadRtFeishuSettings();
|
||
}
|
||
|
||
async function loadRtFeishuSettings() {
|
||
try {
|
||
const data = await fetchJSON("/api/realtime/feishu");
|
||
const en = $("rtFeishuEnabled");
|
||
const wh = $("rtFeishuWebhook");
|
||
const sec = $("rtFeishuSecret");
|
||
if (en) en.checked = !!data.enabled;
|
||
if (wh) wh.value = data.webhook_url || "";
|
||
if (sec) sec.value = data.secret || "";
|
||
} catch (e) {
|
||
const hint = $("rtFeishuHint");
|
||
if (hint) {
|
||
hint.textContent = "加载飞书设置失败: " + e.message;
|
||
hint.classList.add("bad");
|
||
}
|
||
}
|
||
}
|
||
|
||
async function saveRtFeishuSettings() {
|
||
const hint = $("rtFeishuHint");
|
||
const btn = $("rtFeishuSaveBtn");
|
||
if (btn) btn.disabled = true;
|
||
try {
|
||
await fetchJSON("/api/realtime/feishu", {
|
||
method: "PUT",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
enabled: !!$("rtFeishuEnabled")?.checked,
|
||
webhook_url: $("rtFeishuWebhook")?.value || "",
|
||
secret: $("rtFeishuSecret")?.value || "",
|
||
}),
|
||
});
|
||
if (hint) {
|
||
hint.textContent = "✓ 已保存,方向转折时会推送到飞书群。";
|
||
hint.classList.remove("bad", "invalid");
|
||
hint.classList.add("valid");
|
||
}
|
||
if (btn) {
|
||
const old = btn.textContent;
|
||
btn.textContent = "已保存";
|
||
setTimeout(() => {
|
||
if (btn.textContent === "已保存") btn.textContent = old || "保存";
|
||
}, 1600);
|
||
}
|
||
} catch (e) {
|
||
if (hint) {
|
||
hint.textContent = "保存失败: " + e.message;
|
||
hint.classList.remove("valid");
|
||
hint.classList.add("bad", "invalid");
|
||
}
|
||
} finally {
|
||
if (btn) btn.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function testRtFeishu() {
|
||
const hint = $("rtFeishuHint");
|
||
const btn = $("rtFeishuTestBtn");
|
||
if (btn) btn.disabled = true;
|
||
try {
|
||
await fetchJSON("/api/realtime/feishu/test", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
webhook_url: $("rtFeishuWebhook")?.value || "",
|
||
secret: $("rtFeishuSecret")?.value || "",
|
||
}),
|
||
});
|
||
if (hint) {
|
||
hint.textContent = "✓ 测试消息已发送,请到飞书群查收。";
|
||
hint.classList.remove("bad", "invalid");
|
||
hint.classList.add("valid");
|
||
}
|
||
} catch (e) {
|
||
if (hint) {
|
||
hint.textContent = "测试失败: " + e.message;
|
||
hint.classList.remove("valid");
|
||
hint.classList.add("bad", "invalid");
|
||
}
|
||
} finally {
|
||
if (btn) btn.disabled = false;
|
||
}
|
||
}
|
||
|
||
function openRtFeishuHelpModal() {
|
||
const modal = $("rtFeishuHelpModal");
|
||
if (modal) modal.hidden = false;
|
||
}
|
||
|
||
function closeRtFeishuHelpModal() {
|
||
const modal = $("rtFeishuHelpModal");
|
||
if (modal) modal.hidden = true;
|
||
}
|
||
|
||
async function loadRtStrategies() {
|
||
const sel = $("rtStrategySelect");
|
||
if (!sel) return;
|
||
let rows = [];
|
||
try {
|
||
const data = await fetchJSON("/api/realtime/strategies");
|
||
rows = data.strategies || [];
|
||
} catch (_) {}
|
||
const opts = ['<option value="">— 选择已保存策略 —</option>'];
|
||
if (rtImportedStrategy) {
|
||
const isym = escHtml(rtImportedStrategy.symbol || "");
|
||
opts.push(
|
||
`<option value="${escHtml(rtImportedStrategy.path)}" data-symbol="${isym}">导入: ${escHtml(rtImportedStrategy.name)}</option>`
|
||
);
|
||
}
|
||
rows.forEach((r) => {
|
||
const score = r.best_score != null ? Number(r.best_score).toFixed(3) : "—";
|
||
const tf = r.timeframe ? ` ${r.timeframe}` : "";
|
||
opts.push(
|
||
`<option value="${escHtml(r.strategy_file)}" data-symbol="${escHtml(r.symbol || "")}">${escHtml(r.symbol)}${tf} · 分数 ${score}</option>`
|
||
);
|
||
});
|
||
const prev = sel.value;
|
||
sel.innerHTML = opts.join("");
|
||
if (rtImportedStrategy) sel.value = rtImportedStrategy.path;
|
||
else if (prev) sel.value = prev;
|
||
onRtStrategyChange();
|
||
}
|
||
|
||
function onRtSourceChange() {
|
||
const src = rtSourceById[$("rtSourceSelect")?.value];
|
||
const tfSel = $("rtTimeframeSelect");
|
||
const presets = $("rtSymbolPresets");
|
||
const hint = $("rtSourceHint");
|
||
if (!src) return;
|
||
if (tfSel) {
|
||
const cur = tfSel.value;
|
||
tfSel.innerHTML = (src.timeframes || []).map((t) => `<option value="${t}">${t}</option>`).join("");
|
||
if (src.timeframes && src.timeframes.includes(cur)) tfSel.value = cur;
|
||
else if (src.timeframes && src.timeframes.includes("1h")) tfSel.value = "1h";
|
||
}
|
||
// 品种输入/下拉切换:presets 较多时(如国内期货 60 个品种)用下拉框
|
||
const symbolInput = $("rtSymbolInput");
|
||
const symbolSelect = $("rtSymbolSelect");
|
||
const useSelect = src.id === "domestic_futures" || (src.presets && src.presets.length > 20);
|
||
if (symbolInput && symbolSelect) {
|
||
if (useSelect) {
|
||
symbolInput.hidden = true;
|
||
symbolSelect.hidden = false;
|
||
symbolSelect.innerHTML = (src.presets || [])
|
||
.map((s) => `<option value="${escHtml(s)}">${escHtml(s)}</option>`)
|
||
.join("");
|
||
symbolSelect.onchange = () => { symbolInput.value = symbolSelect.value; };
|
||
if (symbolSelect.value) symbolInput.value = symbolSelect.value;
|
||
} else {
|
||
symbolInput.hidden = false;
|
||
symbolSelect.hidden = true;
|
||
if (presets) {
|
||
presets.innerHTML = (src.presets || [])
|
||
.map((s) => `<option value="${escHtml(s)}"></option>`)
|
||
.join("");
|
||
}
|
||
}
|
||
} else if (presets) {
|
||
presets.innerHTML = (src.presets || [])
|
||
.map((s) => `<option value="${escHtml(s)}"></option>`)
|
||
.join("");
|
||
}
|
||
if (hint) {
|
||
hint.textContent = `${src.label}:${src.hint || ""}`;
|
||
hint.classList.toggle("bad", !src.available);
|
||
}
|
||
}
|
||
|
||
function rtParseSymbolFromFilename(pathOrName) {
|
||
const name = String(pathOrName || "").split(/[/\\]/).pop() || "";
|
||
let m = name.match(/^best_(.+)\.json$/i);
|
||
if (m) return m[1];
|
||
m = name.match(/^strategy_(.+)_step\d+/i);
|
||
if (m) return m[1];
|
||
return "";
|
||
}
|
||
|
||
function rtApplySymbolFromStrategy(sym) {
|
||
const s = String(sym || "").trim();
|
||
if (!s) return;
|
||
const input = $("rtSymbolInput");
|
||
if (input) input.value = s;
|
||
const select = $("rtSymbolSelect");
|
||
if (select && !select.hidden) select.value = s;
|
||
}
|
||
|
||
function onRtStrategyChange() {
|
||
const sel = $("rtStrategySelect");
|
||
const picked = $("rtStrategyPicked");
|
||
if (!sel || !picked) return;
|
||
const opt = sel.options[sel.selectedIndex];
|
||
picked.textContent = sel.value
|
||
? `因子来源:${opt ? opt.textContent : sel.value}。信号取最后已收盘 bar。`
|
||
: "因子来源:从已保存策略下拉选择,或「导入策略」选本地 JSON。信号取最后已收盘 bar。";
|
||
if (!sel.value) return;
|
||
const fromOpt = (opt && opt.dataset.symbol) || "";
|
||
const fromImport =
|
||
rtImportedStrategy && sel.value === rtImportedStrategy.path
|
||
? rtImportedStrategy.symbol || ""
|
||
: "";
|
||
const sym = fromOpt || fromImport || rtParseSymbolFromFilename(sel.value);
|
||
rtApplySymbolFromStrategy(sym);
|
||
}
|
||
|
||
async function rtBrowseStrategy() {
|
||
try {
|
||
const res = await fetchJSON("/api/strategy-file/browse", { method: "POST" });
|
||
if (res.cancelled) return;
|
||
const name = res.filename || res.strategy_file;
|
||
rtImportedStrategy = {
|
||
path: res.strategy_file,
|
||
name,
|
||
symbol: (res.symbol || "").trim() || rtParseSymbolFromFilename(name),
|
||
};
|
||
await loadRtStrategies();
|
||
rtApplySymbolFromStrategy(rtImportedStrategy.symbol);
|
||
} catch (e) {
|
||
await logClientError("导入策略失败: " + e.message);
|
||
}
|
||
}
|
||
|
||
async function rtAddWatch() {
|
||
const source = $("rtSourceSelect")?.value;
|
||
const symbol = ($("rtSymbolInput")?.value || "").trim();
|
||
const timeframe = $("rtTimeframeSelect")?.value;
|
||
const strategy_file = $("rtStrategySelect")?.value;
|
||
const picked = $("rtStrategyPicked");
|
||
if (!symbol) {
|
||
if (picked) { picked.textContent = "请填写品种"; picked.classList.add("bad"); }
|
||
return;
|
||
}
|
||
if (!strategy_file) {
|
||
if (picked) { picked.textContent = "请选择或导入策略因子"; picked.classList.add("bad"); }
|
||
return;
|
||
}
|
||
const btn = $("rtAddBtn");
|
||
if (btn) btn.disabled = true;
|
||
try {
|
||
if (source === "tradingview") {
|
||
const reachable = await rtEnsureTradingViewReachable();
|
||
if (!reachable) return;
|
||
}
|
||
await fetchJSON("/api/realtime/watch", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ source, symbol, timeframe, strategy_file }),
|
||
});
|
||
if (picked) picked.classList.remove("bad");
|
||
rtEngineRunning = true;
|
||
rtGridSig = "";
|
||
await refreshRealtime();
|
||
} catch (e) {
|
||
if (picked) { picked.textContent = "添加失败: " + e.message; picked.classList.add("bad"); }
|
||
} finally {
|
||
if (btn) btn.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function rtEnsureTradingViewReachable() {
|
||
const picked = $("rtStrategyPicked");
|
||
if (picked) {
|
||
picked.textContent = "正在检测 TradingView 连通性…";
|
||
picked.classList.remove("bad");
|
||
}
|
||
try {
|
||
const res = await fetchJSON("/api/realtime/tradingview/probe", {
|
||
method: "POST",
|
||
silent: true,
|
||
});
|
||
if (res && res.ok) {
|
||
if (picked) picked.textContent = "";
|
||
return true;
|
||
}
|
||
if (res?.wiki_url) rtTvWikiUrl = res.wiki_url;
|
||
await showTvBlockedDialog(res?.message || RT_TV_BLOCKED_MSG);
|
||
if (picked) {
|
||
picked.textContent = "TradingView 不可用,请开 VPN 或换云服务器";
|
||
picked.classList.add("bad");
|
||
}
|
||
return false;
|
||
} catch (e) {
|
||
await showTvBlockedDialog(RT_TV_BLOCKED_MSG);
|
||
if (picked) {
|
||
picked.textContent = "TradingView 检测失败: " + (e.message || e);
|
||
picked.classList.add("bad");
|
||
}
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function showTvBlockedDialog(message) {
|
||
const now = Date.now();
|
||
// 避免轮询反复弹出
|
||
if (now - rtTvBlockedShownAt < 60_000) {
|
||
return Promise.resolve("dedupe");
|
||
}
|
||
rtTvBlockedShownAt = now;
|
||
const modal = $("tvBlockedModal");
|
||
const body = $("tvBlockedBody");
|
||
if (!modal || !body) {
|
||
window.alert(message || RT_TV_BLOCKED_MSG);
|
||
return Promise.resolve("alert");
|
||
}
|
||
body.textContent = message || RT_TV_BLOCKED_MSG;
|
||
modal.hidden = false;
|
||
return new Promise((resolve) => {
|
||
modal._tvResolve = resolve;
|
||
});
|
||
}
|
||
|
||
function closeTvBlockedDialog(choice) {
|
||
const modal = $("tvBlockedModal");
|
||
if (modal) modal.hidden = true;
|
||
const resolve = modal && modal._tvResolve;
|
||
if (resolve) {
|
||
modal._tvResolve = null;
|
||
resolve(choice || "cancel");
|
||
}
|
||
}
|
||
|
||
async function onTvBlockedSwitchMt5() {
|
||
const sel = $("rtSourceSelect");
|
||
if (sel) {
|
||
const hasMt5 = [...sel.options].some((o) => o.value === "mt5");
|
||
if (hasMt5) {
|
||
sel.value = "mt5";
|
||
onRtSourceChange();
|
||
}
|
||
}
|
||
closeTvBlockedDialog("mt5");
|
||
}
|
||
|
||
function onTvBlockedOpenCloud() {
|
||
try {
|
||
window.open(rtTvWikiUrl, "_blank", "noopener,noreferrer");
|
||
} catch (_) {}
|
||
closeTvBlockedDialog("cloud");
|
||
}
|
||
|
||
function maybeShowTvBlockedFromWatches(watches) {
|
||
const hit = (watches || []).find(
|
||
(w) =>
|
||
w.source === "tradingview" &&
|
||
(w.tv_blocked || w.message === RT_TV_BLOCKED_CODE)
|
||
);
|
||
if (!hit) return;
|
||
showTvBlockedDialog(RT_TV_BLOCKED_MSG);
|
||
}
|
||
|
||
async function rtRemoveWatch(id) {
|
||
try {
|
||
await fetchJSON("/api/realtime/unwatch", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ id }),
|
||
});
|
||
rtGridSig = "";
|
||
await refreshRealtime();
|
||
} catch (e) {
|
||
await logClientError("移除监控失败: " + e.message);
|
||
}
|
||
}
|
||
|
||
async function refreshRealtime() {
|
||
let st;
|
||
try {
|
||
st = await fetchJSON("/api/realtime/status", { silent: true });
|
||
} catch (_) {
|
||
return;
|
||
}
|
||
// 有监控项却未在跑时自动拉起(不再提供手动开关)
|
||
if (st.count > 0 && !st.running) {
|
||
try {
|
||
st = await fetchJSON("/api/realtime/start", { method: "POST", silent: true });
|
||
} catch (_) {}
|
||
}
|
||
rtEngineRunning = !!st.running;
|
||
if (typeof st.server_time === "number") {
|
||
rtServerSkew = st.server_time - Date.now() / 1000;
|
||
}
|
||
|
||
const nearest = st.nearest_seconds_to_next;
|
||
let nearestClose = null;
|
||
let anyLive = false;
|
||
let anyOk = false;
|
||
for (const w of st.watches || []) {
|
||
if (w.state === "ok") anyOk = true;
|
||
if (w.session_live && w.next_bar_close_at != null) {
|
||
anyLive = true;
|
||
if (nearestClose == null || w.next_bar_close_at < nearestClose) {
|
||
nearestClose = w.next_bar_close_at;
|
||
}
|
||
}
|
||
}
|
||
|
||
const hint = $("rtStatusHint");
|
||
if (hint) {
|
||
const base = st.count
|
||
? `${rtEngineRunning ? "运行中" : "已暂停"} · ${st.count} 项`
|
||
: "暂无监控项";
|
||
if (nearestClose) {
|
||
hint.innerHTML = `${base} · <span id="rtNextHint" data-next-close="${nearestClose}"></span>`;
|
||
} else if (anyOk && !anyLive) {
|
||
hint.innerHTML = `${base} · <span id="rtNextHint" data-session="closed">休市中</span>`;
|
||
} else {
|
||
hint.textContent = base;
|
||
}
|
||
}
|
||
renderRealtimeGrid(st.watches || []);
|
||
maybeShowTvBlockedFromWatches(st.watches || []);
|
||
ensureRtCountdownTimer();
|
||
tickRtCountdowns();
|
||
}
|
||
|
||
// 半环表盘(180° 上半环,值弧按强度填充)
|
||
const RT_ARC_LEN = 150.8; // π * 48
|
||
function halfRingGauge(strength, colorHex) {
|
||
const s = Math.max(0, Math.min(1, strength || 0));
|
||
const off = RT_ARC_LEN * (1 - s);
|
||
return `<svg class="rt-gauge-svg" viewBox="0 0 120 74" aria-hidden="true">
|
||
<path class="rt-gauge-track" d="M12 62 A 48 48 0 0 1 108 62" />
|
||
<path class="rt-gauge-val" d="M12 62 A 48 48 0 0 1 108 62"
|
||
style="stroke:${colorHex};stroke-dasharray:${RT_ARC_LEN};stroke-dashoffset:${off.toFixed(1)};" />
|
||
</svg>`;
|
||
}
|
||
|
||
function renderRealtimeGrid(watches) {
|
||
const grid = $("rtGrid");
|
||
if (!grid) return;
|
||
if (!watches.length) {
|
||
grid.innerHTML =
|
||
'<div class="metric-empty">尚无监控项。添加「数据源 + 品种 + 周期 + 因子」后开始实时分析。</div>';
|
||
rtGridSig = "";
|
||
return;
|
||
}
|
||
|
||
// 签名:只在信号相关字段变化时重建(避免每次轮询重播动画)
|
||
const sig = watches
|
||
.map((w) =>
|
||
[
|
||
w.id,
|
||
w.state,
|
||
w.direction,
|
||
w.strength,
|
||
w.warn,
|
||
w.message,
|
||
w.last_bar_ts,
|
||
w.updated_at,
|
||
w.session_live ? 1 : 0,
|
||
w.next_bar_close_at || "",
|
||
].join("~")
|
||
)
|
||
.join("|");
|
||
// 签名未变时仍同步休市/倒计时锚点
|
||
if (sig === rtGridSig) {
|
||
watches.forEach((w) => {
|
||
const el = grid.querySelector(`.rt-card[data-id="${CSS.escape(w.id)}"] .rt-countdown`);
|
||
if (!el) return;
|
||
if (w.session_live && w.next_bar_close_at) {
|
||
el.dataset.session = "";
|
||
el.dataset.nextClose = String(w.next_bar_close_at);
|
||
} else if (w.state === "ok") {
|
||
el.dataset.nextClose = "";
|
||
el.dataset.session = "closed";
|
||
} else {
|
||
el.dataset.nextClose = "";
|
||
el.dataset.session = "";
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
rtGridSig = sig;
|
||
|
||
grid.innerHTML = watches
|
||
.map((w) => {
|
||
const dir = w.state === "ok" ? RT_DIR[w.direction] || RT_DIR.FLAT : null;
|
||
const color = dir ? dir.color : "#7a8a9e";
|
||
const strength = w.state === "ok" ? w.strength || 0 : 0;
|
||
const dirKey = w.state === "ok" ? w.direction : null;
|
||
const plain = w.state === "ok" ? rtSizePlain(strength, dirKey) : null;
|
||
const dirLabel = dir ? dir.label : RT_STATE_LABEL[w.state] || w.state;
|
||
const dirCls = dir ? dir.cls : "rt-flat";
|
||
const srcLabel = (rtSourceById[w.source] || {}).label || w.source;
|
||
const factorText = w.factor_value != null ? Number(w.factor_value).toFixed(4) : "—";
|
||
const warn = w.warn ? `<div class="rt-warn" title="${escHtml(w.warn)}">⚠ ${escHtml(w.warn)}</div>` : "";
|
||
const displayMsg =
|
||
w.message === RT_TV_BLOCKED_CODE || w.tv_blocked
|
||
? "无法连接 TradingView:请开启全局 VPN(TUN)或使用云服务器"
|
||
: w.message;
|
||
const msg =
|
||
w.state !== "ok" && displayMsg
|
||
? `<div class="rt-msg">${escHtml(displayMsg)}</div>`
|
||
: "";
|
||
const sizeText = plain ? plain.size : "—";
|
||
return `
|
||
<div class="rt-card ${dirCls}" data-id="${escHtml(w.id)}">
|
||
<button class="rt-remove" data-remove="${escHtml(w.id)}" title="移除监控">×</button>
|
||
<div class="rt-card-head">
|
||
<span class="rt-sym">${escHtml(w.symbol)}</span>
|
||
<span class="rt-tf">${escHtml(w.timeframe)}</span>
|
||
<span class="rt-src">${escHtml(srcLabel)}</span>
|
||
</div>
|
||
<div class="rt-gauge">
|
||
${halfRingGauge(strength, color)}
|
||
<div class="rt-gauge-center">
|
||
<div class="rt-strength">${escHtml(sizeText)}</div>
|
||
<div class="rt-dir ${dirCls}">${dirLabel}</div>
|
||
</div>
|
||
</div>
|
||
<div class="rt-meta">
|
||
<span class="rt-meta-item">因子 <b>${factorText}</b></span>
|
||
<span class="rt-meta-item">${escHtml(w.strategy_name)}</span>
|
||
</div>
|
||
<div class="rt-foot">
|
||
<span class="rt-state ${w.state}">${RT_STATE_LABEL[w.state] || w.state}</span>
|
||
<span class="rt-time">更新 ${rtClock(w.updated_at)}</span>
|
||
<span class="rt-countdown"${
|
||
w.session_live && w.next_bar_close_at
|
||
? ` data-next-close="${w.next_bar_close_at}"`
|
||
: w.state === "ok"
|
||
? ` data-session="closed"`
|
||
: ""
|
||
}>${
|
||
w.session_live && w.next_bar_close_at
|
||
? "距离下次判断 …"
|
||
: w.state === "ok"
|
||
? "休市中"
|
||
: "距离下次判断 —"
|
||
}</span>
|
||
</div>
|
||
${warn}
|
||
${msg}
|
||
</div>`;
|
||
})
|
||
.join("");
|
||
|
||
runCountUp(grid);
|
||
}
|
||
|
||
function startPolling() {
|
||
if (pollTimer) clearInterval(pollTimer);
|
||
pollTimer = setInterval(() => {
|
||
refreshOverview();
|
||
if (currentPage === "backtest" || btActive) refreshBacktest();
|
||
if (currentPage === "realtime" || rtEngineRunning) refreshRealtime();
|
||
}, 4000);
|
||
}
|
||
|
||
async function init() {
|
||
try {
|
||
await loadConfig();
|
||
await refreshOverview();
|
||
} catch (e) {
|
||
await logClientError("初始化失败: " + e.message);
|
||
}
|
||
$("browseBtn").addEventListener("click", browseDataFile);
|
||
$("startBtn").addEventListener("click", startTraining);
|
||
if ($("retrainBtn")) $("retrainBtn").addEventListener("click", retrainFromScratch);
|
||
$("stopBtn").addEventListener("click", stopTraining);
|
||
$("exportBtn").addEventListener("click", exportStrategy);
|
||
$("exportTrainingBtn").addEventListener("click", exportTraining);
|
||
$("importTrainingBtn").addEventListener("click", triggerImportTraining);
|
||
$("importTrainingFile").addEventListener("change", handleImportTrainingFile);
|
||
$("debugModeCheck").addEventListener("change", (e) => setDebugMode(e.target.checked));
|
||
if ($("aiApiKeyInput")) {
|
||
$("aiApiKeyInput").addEventListener("input", updateAiChannelHint);
|
||
$("aiApiKeyInput").addEventListener("change", updateAiChannelHint);
|
||
}
|
||
if ($("aiAnalyzeBtn")) $("aiAnalyzeBtn").addEventListener("click", runAiAnalyze);
|
||
if ($("aiUnlimitedBtn")) $("aiUnlimitedBtn").addEventListener("click", openUnlimitedModal);
|
||
document.querySelectorAll("[data-close-unlimited]").forEach((el) => {
|
||
el.addEventListener("click", closeUnlimitedModal);
|
||
});
|
||
document.querySelectorAll("[data-close-error]").forEach((el) => {
|
||
el.addEventListener("click", closeErrorPopup);
|
||
});
|
||
if ($("errorModalCopyBtn")) {
|
||
$("errorModalCopyBtn").addEventListener("click", copyErrorPopupDetail);
|
||
}
|
||
|
||
// 步骤导航
|
||
document.querySelectorAll(".stepper .step").forEach((btn) => {
|
||
btn.addEventListener("click", () => switchPage(btn.dataset.page));
|
||
});
|
||
|
||
// 回测控制
|
||
if ($("btBrowseStrategyBtn")) $("btBrowseStrategyBtn").addEventListener("click", browseStrategyFile);
|
||
if ($("btStartBtn")) $("btStartBtn").addEventListener("click", startBacktest);
|
||
if ($("btStopBtn")) $("btStopBtn").addEventListener("click", stopBacktest);
|
||
["btCommissionInput", "btSlippageInput"].forEach((id) => {
|
||
const el = $(id);
|
||
if (!el) return;
|
||
el.addEventListener("input", updateBtCostHint);
|
||
el.addEventListener("change", updateBtCostHint);
|
||
});
|
||
|
||
// 实时分析控制
|
||
if ($("rtSourceSelect")) $("rtSourceSelect").addEventListener("change", onRtSourceChange);
|
||
if ($("rtStrategySelect")) $("rtStrategySelect").addEventListener("change", onRtStrategyChange);
|
||
if ($("rtBrowseStrategyBtn")) $("rtBrowseStrategyBtn").addEventListener("click", rtBrowseStrategy);
|
||
if ($("rtAddBtn")) $("rtAddBtn").addEventListener("click", rtAddWatch);
|
||
if ($("tvBlockedMt5Btn")) $("tvBlockedMt5Btn").addEventListener("click", onTvBlockedSwitchMt5);
|
||
if ($("tvBlockedCloudBtn")) $("tvBlockedCloudBtn").addEventListener("click", onTvBlockedOpenCloud);
|
||
document.querySelectorAll("[data-close-tv-blocked]").forEach((el) => {
|
||
el.addEventListener("click", () => closeTvBlockedDialog("cancel"));
|
||
});
|
||
if ($("rtFeishuSaveBtn")) $("rtFeishuSaveBtn").addEventListener("click", saveRtFeishuSettings);
|
||
if ($("rtFeishuTestBtn")) $("rtFeishuTestBtn").addEventListener("click", testRtFeishu);
|
||
if ($("rtFeishuHelpBtn")) $("rtFeishuHelpBtn").addEventListener("click", openRtFeishuHelpModal);
|
||
document.querySelectorAll("[data-close-feishu-help]").forEach((el) => {
|
||
el.addEventListener("click", closeRtFeishuHelpModal);
|
||
});
|
||
if ($("rtGrid")) {
|
||
$("rtGrid").addEventListener("click", (e) => {
|
||
const btn = e.target.closest("[data-remove]");
|
||
if (btn) rtRemoveWatch(btn.dataset.remove);
|
||
});
|
||
}
|
||
|
||
startPolling();
|
||
}
|
||
|
||
init();
|