Add hub-only OKX amp stats for ETH/BTC session windows.
Read-only 1H index candles, point amplitude metrics, history save and CSV export; no order-path changes. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* 中控振幅统计:OKX ETH/BTC 时段点数振幅.
|
||||
*/
|
||||
(function () {
|
||||
const page = document.getElementById("page-amp-stats");
|
||||
if (!page) return;
|
||||
|
||||
let meta = null;
|
||||
let lastResult = null;
|
||||
let pageNo = 1;
|
||||
let bound = false;
|
||||
|
||||
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, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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 = '<p class="amp-empty">暂无汇总</p>';
|
||||
return;
|
||||
}
|
||||
box.innerHTML =
|
||||
`<div class="amp-sum-grid">` +
|
||||
`<div><span class="amp-sum-k">样本</span><span class="amp-sum-v">${esc(s.sample_count)}</span></div>` +
|
||||
`<div><span class="amp-sum-k">最大振幅</span><span class="amp-sum-v">${esc(s.max_amplitude)} <small>(${esc(s.max_amplitude_day)})</small></span></div>` +
|
||||
`<div><span class="amp-sum-k">振幅均值</span><span class="amp-sum-v">${esc(s.avg_amplitude)}</span></div>` +
|
||||
`<div><span class="amp-sum-k">振幅中位</span><span class="amp-sum-v">${esc(s.median_amplitude)}</span></div>` +
|
||||
`<div><span class="amp-sum-k">开→高最大/均</span><span class="amp-sum-v">${esc(s.max_up_points)} / ${esc(s.avg_up_points)}</span></div>` +
|
||||
`<div><span class="amp-sum-k">开→低最大/均</span><span class="amp-sum-v">${esc(s.max_down_points)} / ${esc(s.avg_down_points)}</span></div>` +
|
||||
`<div><span class="amp-sum-k">涨/跌窗占比</span><span class="amp-sum-v">${esc(s.up_day_ratio)} / ${esc(s.down_day_ratio)}</span></div>` +
|
||||
`<div><span class="amp-sum-k">价源</span><span class="amp-sum-v">${esc(result && result.price_source)}</span></div>` +
|
||||
`</div>`;
|
||||
}
|
||||
|
||||
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 = '<tr><td colspan="11" class="amp-empty">暂无数据</td></tr>';
|
||||
} else {
|
||||
body.innerHTML = rows
|
||||
.map(
|
||||
(r) =>
|
||||
`<tr>` +
|
||||
`<td>${esc(r.settlement_day)}</td>` +
|
||||
`<td>${esc(r.window_start)}</td>` +
|
||||
`<td>${esc(r.open)}</td>` +
|
||||
`<td>${esc(r.high)}</td>` +
|
||||
`<td>${esc(r.low)}</td>` +
|
||||
`<td>${esc(r.close)}</td>` +
|
||||
`<td>${esc(r.up_points)}</td>` +
|
||||
`<td>${esc(r.down_points)}</td>` +
|
||||
`<td><strong>${esc(r.amplitude)}</strong></td>` +
|
||||
`<td>${esc(r.change)}</td>` +
|
||||
`</tr>`
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
if (pager && pagePayload) {
|
||||
pager.innerHTML =
|
||||
`<button type="button" class="ghost" id="amp-page-prev" ${pagePayload.page <= 1 ? "disabled" : ""}>上一页</button>` +
|
||||
`<span class="amp-pager-meta">第 ${esc(pagePayload.page)} / ${esc(pagePayload.total_pages)} 页 · 共 ${esc(pagePayload.total)} 天</span>` +
|
||||
`<button type="button" class="ghost" id="amp-page-next" ${pagePayload.page >= pagePayload.total_pages ? "disabled" : ""}>下一页</button>`;
|
||||
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 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 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,
|
||||
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 q = new URLSearchParams({
|
||||
symbol,
|
||||
start_hour: String(startHour),
|
||||
period,
|
||||
});
|
||||
if (period === "custom") q.set("custom_days", String(customDays));
|
||||
window.location.href = "/api/amp-stats/export?" + q.toString();
|
||||
}
|
||||
|
||||
async function loadHistory() {
|
||||
const box = el("amp-history-list");
|
||||
if (!box) return;
|
||||
box.innerHTML = '<p class="amp-empty">加载中…</p>';
|
||||
try {
|
||||
const data = await apiFetch("/api/amp-stats/history?limit=50");
|
||||
const items = data.items || [];
|
||||
if (!items.length) {
|
||||
box.innerHTML = '<p class="amp-empty">暂无历史</p>';
|
||||
return;
|
||||
}
|
||||
box.innerHTML = items
|
||||
.map(
|
||||
(it) =>
|
||||
`<div class="amp-hist-card" data-id="${esc(it.id)}">` +
|
||||
`<div class="amp-hist-main">` +
|
||||
`<strong>${esc(it.symbol_label || it.symbol)}</strong> · ${esc(String(it.start_hour).padStart(2, "0"))}:00→16:00 · ${esc(it.period)}` +
|
||||
`<div class="amp-hist-sub">${esc(it.created_at)} · 样本 ${esc(it.sample_count)} · 最大振幅 ${esc(it.max_amplitude)} (${esc(it.max_amplitude_day)})</div>` +
|
||||
`</div>` +
|
||||
`<div class="amp-hist-actions">` +
|
||||
`<button type="button" class="ghost amp-hist-view">查看</button>` +
|
||||
`<button type="button" class="ghost amp-hist-dl">下载</button>` +
|
||||
`<button type="button" class="danger amp-hist-del">删除</button>` +
|
||||
`</div></div>`
|
||||
)
|
||||
.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", () => {
|
||||
window.location.href = "/api/amp-stats/export?history_id=" + encodeURIComponent(id);
|
||||
});
|
||||
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 = `<p class="amp-empty">${esc(String(e && e.message ? e.message : e))}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
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);
|
||||
}
|
||||
} 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);
|
||||
syncCustomDays();
|
||||
}
|
||||
|
||||
window.hubAmpStatsPage = {
|
||||
init() {
|
||||
bind();
|
||||
setView("stats");
|
||||
setStatus("");
|
||||
},
|
||||
};
|
||||
})();
|
||||
Reference in New Issue
Block a user