/** * 中控振幅统计: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 = `` + `第 ${esc(pagePayload.page)} / ${esc(pagePayload.total_pages)} 页 · 共 ${esc(pagePayload.total)} 天` + ``; 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); }, }; })();