feat(hub): add macro calendar for pre-release risk alerts

Manual FOMC/CPI/employment entries in settings drive ±1h monitor banners without touching exchange instances.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-18 11:52:30 +08:00
parent 3d29b4f9d9
commit e470c5952f
7 changed files with 932 additions and 3 deletions
+215
View File
@@ -1075,6 +1075,7 @@
function stopMonitorPoll() {
closeMonitorBoardStream();
stopHostStatusPoll();
stopMacroBannerPoll();
if (sseReconnectTimer) {
clearTimeout(sseReconnectTimer);
sseReconnectTimer = null;
@@ -1210,14 +1211,86 @@
: "";
}
updateMonitorAlertSummary(rows || []);
void refreshMacroRiskBanner(rows || []);
renderMonitorGrid(rows || []);
}
let macroBannerTimer = null;
let macroCalendarEditId = null;
function monitorHasOpenPositions(rows) {
return (rows || []).some((row) => {
const pos = (row.agent && row.agent.positions) || [];
return Array.isArray(pos) && pos.length > 0;
});
}
function macroAlertMessage(alert, hasPositions) {
const label = alert.event_type_label || alert.event_type || "宏观数据";
const phase = alert.phase || "window";
const mins = Number(alert.minutes_to_event || 0);
if (hasPositions) {
if (phase === "imminent" && mins > 0) {
return (
`${label}」即将发布(约 ${mins} 分钟),` +
"注意仓位风险:勿加仓,检查止损/减仓"
);
}
return `${label}」高波动窗口(±1h),注意仓位风险:勿加仓,检查止损/减仓`;
}
if (phase === "imminent" && mins > 0) {
return `${label}」即将发布(约 ${mins} 分钟),建议等待,避免新开仓`;
}
return `${label}」高波动窗口(±1h),建议等待,避免新开仓`;
}
async function refreshMacroRiskBanner(rows) {
if (currentPage() !== "monitor") return;
const el = document.getElementById("monitor-macro-banner");
const textEl = document.getElementById("monitor-macro-banner-text");
if (!el || !textEl) return;
try {
const r = await apiFetch("/api/macro-calendar/active");
const j = await r.json();
const alerts = (j.ok && j.alerts) || [];
if (!alerts.length) {
el.classList.add("hidden");
el.classList.remove("phase-imminent");
textEl.textContent = "";
return;
}
const alert = alerts[0];
const hasPos = monitorHasOpenPositions(rows || lastMonitorRows);
textEl.textContent = macroAlertMessage(alert, hasPos);
el.classList.toggle("phase-imminent", alert.phase === "imminent");
el.classList.remove("hidden");
} catch (_) {
el.classList.add("hidden");
}
}
function startMacroBannerPoll() {
stopMacroBannerPoll();
if (currentPage() !== "monitor") return;
void refreshMacroRiskBanner(lastMonitorRows);
macroBannerTimer = setInterval(() => {
if (currentPage() === "monitor") void refreshMacroRiskBanner(lastMonitorRows);
}, 30000);
}
function stopMacroBannerPoll() {
if (macroBannerTimer) {
clearInterval(macroBannerTimer);
macroBannerTimer = null;
}
}
function startMonitorPoll() {
const hadCache = restoreMonitorBoardFromCache();
void fetchMonitorBoardSnapshot({ showLoading: !hadCache });
connectMonitorBoardStream();
startHostStatusPoll();
startMacroBannerPoll();
}
async function loadSettings() {
@@ -3469,8 +3542,150 @@
});
}
function macroDatetimeLocalToApi(v) {
if (!v) return "";
return String(v).trim().replace("T", " ").slice(0, 16);
}
function macroApiToDatetimeLocal(s) {
if (!s) return "";
return String(s).trim().replace(" ", "T").slice(0, 16);
}
function resetMacroEventForm() {
macroCalendarEditId = null;
const form = document.getElementById("macro-event-form");
const cancel = document.getElementById("macro-event-cancel");
const submit = document.getElementById("macro-event-submit");
if (form) form.reset();
if (cancel) cancel.classList.add("hidden");
if (submit) submit.textContent = "添加";
}
function renderMacroEventList(events) {
const box = document.getElementById("macro-event-list");
if (!box) return;
const rows = events || [];
if (!rows.length) {
box.innerHTML = '<div class="macro-event-empty">暂无已录入的关键数据。请在上方添加 FOMC / CPI / 就业发布时间。</div>';
return;
}
const now = Date.now();
box.innerHTML = rows
.map((ev) => {
const start = Number(ev.event_at_ms) - 3600000;
const end = Number(ev.event_at_ms) + 3600000;
const active = now >= start && now <= end;
const note = ev.note ? `<div class="macro-event-row-meta">${esc(ev.note)}</div>` : "";
return `<div class="macro-event-row${active ? " is-active" : ""}" data-id="${ev.id}">
<div>
<div class="macro-event-row-title">${esc(ev.event_type_label || ev.event_type)}</div>
${note}
</div>
<div class="macro-event-row-meta">${esc(ev.event_at || "")}</div>
<div class="macro-event-row-meta">${active ? "窗口内" : "待触发"} · ±1h</div>
<div class="macro-event-row-actions">
<button type="button" class="ghost macro-event-edit" data-id="${ev.id}">编辑</button>
<button type="button" class="ghost danger macro-event-del" data-id="${ev.id}">删除</button>
</div>
</div>`;
})
.join("");
box.querySelectorAll(".macro-event-edit").forEach((btn) => {
btn.addEventListener("click", () => {
const id = Number(btn.getAttribute("data-id"));
const row = rows.find((x) => Number(x.id) === id);
if (!row) return;
macroCalendarEditId = id;
const typeEl = document.getElementById("macro-event-type");
const atEl = document.getElementById("macro-event-at");
const noteEl = document.getElementById("macro-event-note");
const cancel = document.getElementById("macro-event-cancel");
const submit = document.getElementById("macro-event-submit");
if (typeEl) typeEl.value = row.event_type || "fomc";
if (atEl) atEl.value = macroApiToDatetimeLocal(row.event_at || "");
if (noteEl) noteEl.value = row.note || "";
if (cancel) cancel.classList.remove("hidden");
if (submit) submit.textContent = "保存";
});
});
box.querySelectorAll(".macro-event-del").forEach((btn) => {
btn.addEventListener("click", async () => {
const id = btn.getAttribute("data-id");
if (!id || !confirm("确定删除这条宏观关键数据?")) return;
try {
const r = await apiFetch(`/api/macro-calendar/events/${id}`, { method: "DELETE" });
const j = await r.json();
if (!j.ok) throw new Error(j.detail || "删除失败");
showToast("已删除");
resetMacroEventForm();
await loadMacroCalendarUI();
void refreshMacroRiskBanner(lastMonitorRows);
} catch (e) {
showToast(String(e), true);
}
});
});
}
async function loadMacroCalendarUI() {
const box = document.getElementById("macro-event-list");
if (!box) return;
try {
const r = await apiFetch("/api/macro-calendar/events");
const j = await r.json();
renderMacroEventList((j.ok && j.events) || []);
} catch (e) {
box.innerHTML = `<div class="macro-event-empty">${esc(String(e))}</div>`;
}
}
function initMacroCalendarSettings() {
const form = document.getElementById("macro-event-form");
const cancel = document.getElementById("macro-event-cancel");
if (cancel) {
cancel.addEventListener("click", () => resetMacroEventForm());
}
if (!form || form.dataset.bound === "1") return;
form.dataset.bound = "1";
form.addEventListener("submit", async (ev) => {
ev.preventDefault();
const typeEl = document.getElementById("macro-event-type");
const atEl = document.getElementById("macro-event-at");
const noteEl = document.getElementById("macro-event-note");
const payload = {
event_type: typeEl ? typeEl.value : "",
event_at: macroDatetimeLocalToApi(atEl ? atEl.value : ""),
note: noteEl ? noteEl.value : "",
};
try {
const editing = macroCalendarEditId != null;
const r = await apiFetch(
editing
? `/api/macro-calendar/events/${macroCalendarEditId}`
: "/api/macro-calendar/events",
{
method: editing ? "PATCH" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}
);
const j = await r.json();
if (!r.ok || !j.ok) throw new Error(j.detail || "保存失败");
showToast(editing ? "已更新" : "已添加");
resetMacroEventForm();
await loadMacroCalendarUI();
void refreshMacroRiskBanner(lastMonitorRows);
} catch (e) {
showToast(String(e), true);
}
});
}
function loadSettingsUI() {
loadSettingsMetaLine();
initMacroCalendarSettings();
loadMacroCalendarUI();
loadSettings().then((data) => {
syncDisplayPrefsUI(data);
renderSettingsList(data);