/** * 中控振幅统计:OKX ETH/BTC 时段点数振幅 + 买跨对照. */ (function () { const page = document.getElementById("page-amp-stats"); if (!page) return; let lastResult = null; let pageNo = 1; let bound = false; let straddleTimer = 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 readPremium() { const raw = (el("amp-straddle-premium")?.value || "").trim(); if (!raw) return null; const n = Number(raw); if (!Number.isFinite(n) || n <= 0) return null; return n; } 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 pnlClass(v) { const n = Number(v); if (!Number.isFinite(n) || n === 0) return ""; return n > 0 ? "is-pos" : "is-neg"; } function renderSummary(summary, result) { const box = el("amp-summary"); if (!box) return; const s = summary || {}; if (!s.sample_count) { box.innerHTML = '

暂无汇总

'; renderStraddle(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_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)}
` + `
`; renderStraddle(s.straddle); } function renderStraddle(st) { const box = el("amp-straddle"); if (!box) return; if (!st) { box.innerHTML = '

填写「买跨·双边权利金」后计算,可看越过天数与买跨点数盈亏

'; return; } const verdict = st.pnl_total == null ? "—" : Number(st.pnl_total) > 0 ? "样本合计盈利" : Number(st.pnl_total) < 0 ? "样本合计亏损" : "样本合计持平"; box.innerHTML = `
` + `
双边权利金${esc(st.premium)}
` + `
开→高超过${esc(st.up_exceed_days)} 天 · ${esc(pct(st.up_exceed_ratio))}
` + `
开→低超过${esc(st.down_exceed_days)} 天 · ${esc(pct(st.down_exceed_ratio))}
` + `
|涨跌|超过${esc(st.abs_change_exceed_days)} 天 · ${esc(pct(st.abs_change_exceed_ratio))}
` + `
买跨盈亏合计${esc(st.pnl_total)} (${esc(verdict)})
` + `
日均盈亏${esc(st.pnl_avg)}
` + `
赚钱天数/胜率${esc(st.win_days)} · ${esc(pct(st.win_ratio))}
` + `
单日最大赚/亏${esc(st.pnl_max)} / ${esc(st.pnl_min)}
` + `
`; } 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) => `` + `${esc(r.settlement_day)}` + `${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)}` + `${esc(r.change)}` + `` ) .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 compute(false); } }); el("amp-page-next")?.addEventListener("click", () => { if (pagePayload.page < pagePayload.total_pages) { pageNo += 1; void compute(false); } }); } } async function applyStraddleOnly() { if (!lastResult || !Array.isArray(lastResult.rows)) { renderStraddle(null); return; } const prem = readPremium(); if (prem == null) { if (lastResult.summary) lastResult.summary.straddle = null; lastResult.straddle_premium = null; renderStraddle(null); return; } try { const data = await apiFetch("/api/amp-stats/straddle", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ rows: lastResult.rows, straddle_premium: prem }), }); const st = data.straddle || null; if (!lastResult.summary) lastResult.summary = {}; lastResult.summary.straddle = st; lastResult.straddle_premium = prem; renderStraddle(st); } catch (e) { setStatus(String(e && e.message ? e.message : e)); } } function scheduleStraddleRefresh() { if (straddleTimer) clearTimeout(straddleTimer); straddleTimer = setTimeout(() => void applyStraddleOnly(), 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); const straddlePremium = readPremium(); setStatus("计算中…(首次拉取 OKX K 线可能需数十秒)"); 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, straddle_premium: straddlePremium, 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 prem = readPremium(); const q = new URLSearchParams({ symbol, start_hour: String(startHour), period, }); if (period === "custom") q.set("custom_days", String(customDays)); if (prem != null) q.set("straddle_premium", String(prem)); 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 prem = readPremium(); let url = "/api/amp-stats/export?history_id=" + encodeURIComponent(id); if (prem != null) url += "&straddle_premium=" + encodeURIComponent(String(prem)); window.location.href = url; }); 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.straddle_premium != null && el("amp-straddle-premium")) { el("amp-straddle-premium").value = String(lastResult.straddle_premium); } renderSummary(lastResult.summary, lastResult); const pagePayload = { page: 1, page_size: 20, total: (lastResult.rows || []).length, total_pages: Math.max(1, Math.ceil((lastResult.rows || []).length / 20)), rows: (lastResult.rows || []).slice(0, 20), }; pageNo = 1; renderTable(pagePayload); setStatus("已载入历史 " + id); void applyStraddleOnly(); } } 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-straddle-premium")?.addEventListener("input", scheduleStraddleRefresh); syncCustomDays(); } window.hubAmpStatsPage = { init() { bind(); setView("stats"); setStatus(""); renderStraddle(null); }, }; })();