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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user