/**
* 中控振幅统计:OKX ETH/BTC + 波动点数振幅占比 + 周末筛选.
*/
(function () {
const page = document.getElementById("page-amp-stats");
if (!page) return;
let lastResult = null;
let pageNo = 1;
let bound = false;
let reframeTimer = null;
const el = (id) => document.getElementById(id);
async function apiFetch(url, opts) {
const r = await fetch(url, { credentials: "same-origin", ...(opts || {}) });
const ct = (r.headers.get("content-type") || "").toLowerCase();
if (ct.includes("application/json")) {
const data = await r.json();
if (!r.ok) throw new Error((data && (data.detail || data.msg)) || r.statusText || "请求失败");
return data;
}
if (!r.ok) throw new Error(r.statusText || "请求失败");
return r;
}
function esc(s) {
return String(s ?? "")
.replace(/&/g, "&")
.replace(//g, ">")
.replace(/"/g, """);
}
function pct(ratio) {
if (ratio == null || ratio === "") return "—";
const n = Number(ratio);
if (!Number.isFinite(n)) return "—";
return (n * 100).toFixed(1) + "%";
}
function readMovePoints() {
const raw = (el("amp-move-points")?.value || "").trim();
if (!raw) return null;
const n = Number(raw);
if (!Number.isFinite(n) || n <= 0) return null;
return n;
}
function readWeekend() {
return el("amp-weekend-filter")?.value || "all";
}
function setStatus(msg) {
const s = el("amp-status");
if (s) s.textContent = msg || "";
}
function setView(view) {
const isHist = view === "history";
el("amp-panel-stats")?.classList.toggle("hidden", isHist);
el("amp-panel-history")?.classList.toggle("hidden", !isHist);
page.querySelectorAll(".amp-view-tab").forEach((btn) => {
const on = btn.getAttribute("data-view") === view;
btn.classList.toggle("is-active", on);
btn.setAttribute("aria-selected", on ? "true" : "false");
});
if (isHist) void loadHistory();
}
function syncCustomDays() {
const period = el("amp-period")?.value || "2m";
const wrap = el("amp-custom-wrap");
if (wrap) wrap.classList.toggle("hidden", period !== "custom");
}
function fillMetaControls() {
const hourSel = el("amp-start-hour");
if (hourSel && !hourSel.options.length) {
for (let h = 0; h < 24; h++) {
const opt = document.createElement("option");
opt.value = String(h);
opt.textContent = String(h).padStart(2, "0") + ":00";
if (h === 16) opt.selected = true;
hourSel.appendChild(opt);
}
}
}
function renderSummary(summary, result) {
const box = el("amp-summary");
if (!box) return;
const s = summary || {};
if (!s.sample_count) {
box.innerHTML = '
暂无汇总
';
renderMoveStats(null);
return;
}
box.innerHTML =
`` +
`
样本${esc(s.sample_count)}
` +
`
最大振幅${esc(s.max_amplitude)} (${esc(s.max_amplitude_day)})
` +
`
振幅均值${esc(s.avg_amplitude)}
` +
`
振幅中位${esc(s.median_amplitude)}
` +
`
两日最大振幅${esc(s.max_amplitude_2d)} (${esc(s.max_amplitude_2d_day)})
` +
`
两日振幅均值/中位${esc(s.avg_amplitude_2d)} / ${esc(s.median_amplitude_2d)}
` +
`
开→高最大/均${esc(s.max_up_points)} / ${esc(s.avg_up_points)}
` +
`
开→低最大/均${esc(s.max_down_points)} / ${esc(s.avg_down_points)}
` +
`
涨/跌窗占比${esc(s.up_day_ratio)} / ${esc(s.down_day_ratio)}
` +
`
价源${esc(result && result.price_source)}
` +
`
`;
renderMoveStats(s.move_points_stats);
}
function renderMoveStats(ms) {
const box = el("amp-move-stats");
if (!box) return;
if (!ms) {
box.innerHTML = '填写「波动点数」后计算,可看振幅≥该点数的天数占比
';
return;
}
box.innerHTML =
`` +
`
对照点数${esc(ms.move_points)}
` +
`
振幅≥点数${esc(ms.amp_hit_days)} 天 · ${esc(pct(ms.amp_hit_ratio))}
` +
`
两日振幅≥点数${esc(ms.amp_2d_hit_days)} 天 · ${esc(pct(ms.amp_2d_hit_ratio))}
` +
`
开→高≥点数${esc(ms.up_hit_days)} 天 · ${esc(pct(ms.up_hit_ratio))}
` +
`
开→低≥点数${esc(ms.down_hit_days)} 天 · ${esc(pct(ms.down_hit_ratio))}
` +
`
|涨跌|≥点数${esc(ms.abs_change_hit_days)} 天 · ${esc(pct(ms.abs_change_hit_ratio))}
` +
`
`;
}
function dayLabel(r) {
const day = esc(r.settlement_day);
if (r.is_weekend && r.weekday_label) {
return `${day}${esc(r.weekday_label)}`;
}
return day;
}
function hitCell(r) {
if (r.move_points == null) return "—";
if (r.amp_hit) return '是';
return '否';
}
function renderTable(pagePayload) {
const body = el("amp-table-body");
const pager = el("amp-pager");
if (!body) return;
const rows = (pagePayload && pagePayload.rows) || [];
if (!rows.length) {
body.innerHTML = '| 暂无数据 |
';
} else {
body.innerHTML = rows
.map((r) => {
const trClass = r.is_weekend ? ' class="amp-row-weekend"' : "";
const upCls = r.hit_up ? ' class="amp-pnl is-pos"' : "";
const downCls = r.hit_down ? ' class="amp-pnl is-pos"' : "";
const ampCls = r.amp_hit ? ' class="amp-pnl is-pos"' : "";
const amp2Cls = r.amp_hit_2d ? ' class="amp-pnl is-pos"' : "";
const amp2 =
r.amplitude_2d == null || r.amplitude_2d === ""
? "—"
: `${esc(r.amplitude_2d)}`;
return (
`` +
`| ${dayLabel(r)} | ` +
`${esc(r.window_start)} | ` +
`${esc(r.open)} | ` +
`${esc(r.high)} | ` +
`${esc(r.low)} | ` +
`${esc(r.close)} | ` +
`${esc(r.up_points)} | ` +
`${esc(r.down_points)} | ` +
`${esc(r.amplitude)} | ` +
`${amp2} | ` +
`${esc(r.change)} | ` +
`${hitCell(r)} | ` +
`
`
);
})
.join("");
}
if (pager && pagePayload) {
pager.innerHTML =
`` +
`` +
``;
el("amp-page-prev")?.addEventListener("click", () => {
if (pageNo > 1) {
pageNo -= 1;
void reframe(false);
}
});
el("amp-page-next")?.addEventListener("click", () => {
if (pagePayload.page < pagePayload.total_pages) {
pageNo += 1;
void reframe(false);
}
});
}
}
function rowsAllFromLast() {
if (!lastResult) return [];
if (Array.isArray(lastResult.rows_all) && lastResult.rows_all.length) return lastResult.rows_all;
return lastResult.rows || [];
}
async function reframe(resetPage) {
if (!lastResult) {
renderMoveStats(null);
return;
}
if (resetPage) pageNo = 1;
const rowsAll = rowsAllFromLast();
if (!rowsAll.length) return;
try {
const data = await apiFetch("/api/amp-stats/reframe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
rows_all: rowsAll,
symbol: lastResult.symbol || el("amp-symbol")?.value || "eth",
start_hour: lastResult.start_hour ?? Number(el("amp-start-hour")?.value || 16),
period: lastResult.period || el("amp-period")?.value || "2m",
sample_days: lastResult.sample_days_requested || 60,
move_points: readMovePoints(),
weekend_filter: readWeekend(),
price_source: lastResult.price_source || "",
inst_id: lastResult.inst_id || "",
page: pageNo,
page_size: 20,
}),
});
const prevAll = rowsAll;
lastResult = data.result || lastResult;
if (!lastResult.rows_all || !lastResult.rows_all.length) lastResult.rows_all = prevAll;
renderSummary(lastResult.summary, lastResult);
renderTable(data.page);
setStatus(`完成 · 样本 ${(lastResult.summary || {}).sample_count || 0}`);
} catch (e) {
setStatus(String(e && e.message ? e.message : e));
}
}
function scheduleReframe() {
if (!lastResult) return;
if (reframeTimer) clearTimeout(reframeTimer);
reframeTimer = setTimeout(() => void reframe(true), 280);
}
async function compute(resetPage) {
if (resetPage) pageNo = 1;
const symbol = el("amp-symbol")?.value || "eth";
const startHour = Number(el("amp-start-hour")?.value || 16);
const period = el("amp-period")?.value || "2m";
const customDays = Number(el("amp-custom-days")?.value || 60);
setStatus("计算中…(长周期会分页拉 OKX,遇限频会自动重试,请稍候)");
try {
const data = await apiFetch("/api/amp-stats/compute", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
symbol,
start_hour: startHour,
period,
custom_days: period === "custom" ? customDays : null,
move_points: readMovePoints(),
weekend_filter: readWeekend(),
page: pageNo,
page_size: 20,
}),
});
lastResult = data.result || null;
renderSummary(lastResult && lastResult.summary, lastResult);
renderTable(data.page);
const miss = (lastResult && lastResult.missing_count) || 0;
setStatus(
miss
? `完成 · 样本 ${(lastResult.summary || {}).sample_count || 0} · 缺 ${miss} 天`
: `完成 · 样本 ${(lastResult.summary || {}).sample_count || 0}`
);
} catch (e) {
setStatus(String(e && e.message ? e.message : e));
}
}
async function saveHistory() {
if (!lastResult) {
setStatus("请先计算");
return;
}
try {
await apiFetch("/api/amp-stats/history", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ result: lastResult }),
});
setStatus("已保存到历史");
} catch (e) {
setStatus(String(e && e.message ? e.message : e));
}
}
function downloadCurrent() {
if (!lastResult) {
setStatus("请先计算");
return;
}
const symbol = el("amp-symbol")?.value || "eth";
const startHour = Number(el("amp-start-hour")?.value || 16);
const period = el("amp-period")?.value || "2m";
const customDays = Number(el("amp-custom-days")?.value || 60);
const mp = readMovePoints();
const q = new URLSearchParams({
symbol,
start_hour: String(startHour),
period,
weekend_filter: readWeekend(),
});
if (period === "custom") q.set("custom_days", String(customDays));
if (mp != null) q.set("move_points", String(mp));
window.location.href = "/api/amp-stats/export?" + q.toString();
}
async function loadHistory() {
const box = el("amp-history-list");
if (!box) return;
box.innerHTML = '加载中…
';
try {
const data = await apiFetch("/api/amp-stats/history?limit=50");
const items = data.items || [];
if (!items.length) {
box.innerHTML = '暂无历史
';
return;
}
box.innerHTML = items
.map(
(it) =>
`` +
`
` +
`
${esc(it.symbol_label || it.symbol)} · ${esc(String(it.start_hour).padStart(2, "0"))}:00→16:00 · ${esc(it.period)}` +
`
${esc(it.created_at)} · 样本 ${esc(it.sample_count)} · 最大振幅 ${esc(it.max_amplitude)} (${esc(it.max_amplitude_day)})
` +
`
` +
`
` +
`` +
`` +
`` +
`
`
)
.join("");
box.querySelectorAll(".amp-hist-card").forEach((card) => {
const id = card.getAttribute("data-id");
card.querySelector(".amp-hist-view")?.addEventListener("click", () => void openHistory(id));
card.querySelector(".amp-hist-dl")?.addEventListener("click", () => {
const mp = readMovePoints();
const q = new URLSearchParams({
history_id: id,
weekend_filter: readWeekend(),
});
if (mp != null) q.set("move_points", String(mp));
window.location.href = "/api/amp-stats/export?" + q.toString();
});
card.querySelector(".amp-hist-del")?.addEventListener("click", async () => {
if (!confirm("删除该历史记录?")) return;
await apiFetch("/api/amp-stats/history/" + encodeURIComponent(id), { method: "DELETE" });
void loadHistory();
});
});
} catch (e) {
box.innerHTML = `${esc(String(e && e.message ? e.message : e))}
`;
}
}
async function openHistory(id) {
try {
const data = await apiFetch("/api/amp-stats/history/" + encodeURIComponent(id));
lastResult = data.item || null;
setView("stats");
if (lastResult) {
if (el("amp-symbol")) el("amp-symbol").value = lastResult.symbol || "eth";
if (el("amp-start-hour")) el("amp-start-hour").value = String(lastResult.start_hour ?? 16);
if (lastResult.move_points != null && el("amp-move-points")) {
el("amp-move-points").value = String(lastResult.move_points);
}
if (lastResult.weekend_filter && el("amp-weekend-filter")) {
el("amp-weekend-filter").value = lastResult.weekend_filter;
}
pageNo = 1;
setStatus("已载入历史 " + id);
await reframe(true);
}
} catch (e) {
setStatus(String(e && e.message ? e.message : e));
}
}
function bind() {
if (bound) return;
bound = true;
fillMetaControls();
page.querySelectorAll(".amp-view-tab").forEach((btn) => {
btn.addEventListener("click", () => setView(btn.getAttribute("data-view")));
});
el("amp-period")?.addEventListener("change", syncCustomDays);
el("amp-btn-compute")?.addEventListener("click", () => void compute(true));
el("amp-btn-save")?.addEventListener("click", () => void saveHistory());
el("amp-btn-download")?.addEventListener("click", downloadCurrent);
el("amp-move-points")?.addEventListener("input", scheduleReframe);
el("amp-weekend-filter")?.addEventListener("change", () => void reframe(true));
syncCustomDays();
}
window.hubAmpStatsPage = {
init() {
bind();
setView("stats");
setStatus("");
renderMoveStats(null);
},
};
})();