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
+133
View File
@@ -651,6 +651,139 @@ button:disabled {
color: var(--muted);
}
.monitor-macro-banner {
margin: 0 0 12px;
padding: 12px 14px;
border-radius: var(--radius);
border: 1px solid rgba(255, 176, 32, 0.45);
background: linear-gradient(90deg, rgba(255, 176, 32, 0.12), rgba(255, 120, 80, 0.08));
}
.monitor-macro-banner.hidden {
display: none !important;
}
.monitor-macro-banner-inner {
display: flex;
align-items: flex-start;
gap: 10px;
flex-wrap: wrap;
}
.monitor-macro-badge {
flex: 0 0 auto;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.06em;
padding: 4px 10px;
border-radius: 999px;
color: #ffb020;
border: 1px solid rgba(255, 176, 32, 0.5);
background: rgba(255, 176, 32, 0.12);
}
.monitor-macro-text {
flex: 1 1 240px;
font-size: 13px;
line-height: 1.5;
color: var(--text);
}
.monitor-macro-banner.phase-imminent {
border-color: rgba(255, 120, 80, 0.55);
background: linear-gradient(90deg, rgba(255, 120, 80, 0.14), rgba(255, 176, 32, 0.1));
}
.settings-macro-panel {
margin-bottom: 16px;
}
.macro-event-form {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
margin: 12px 0 14px;
align-items: end;
}
.macro-event-field {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 12px;
color: var(--muted);
}
.macro-event-field-wide {
grid-column: 1 / -1;
}
.macro-event-field input,
.macro-event-field select {
background: var(--bg-elevated);
border: 1px solid var(--border);
color: var(--text);
border-radius: 8px;
padding: 9px 11px;
font-size: 12px;
font-family: var(--mono);
}
.macro-event-actions {
display: flex;
gap: 8px;
align-items: center;
}
.macro-event-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.macro-event-row {
display: grid;
grid-template-columns: minmax(140px, 1.2fr) minmax(150px, 1fr) minmax(120px, 1fr) auto;
gap: 10px;
align-items: center;
padding: 10px 12px;
border: 1px solid var(--border-soft);
border-radius: var(--radius);
background: var(--panel);
font-size: 12px;
}
.macro-event-row.is-active {
border-color: rgba(255, 176, 32, 0.45);
box-shadow: inset 0 0 0 1px rgba(255, 176, 32, 0.12);
}
.macro-event-row-title {
font-weight: 600;
color: var(--text);
}
.macro-event-row-meta {
color: var(--muted);
font-family: var(--mono);
font-size: 11px;
}
.macro-event-row-actions {
display: flex;
gap: 6px;
justify-content: flex-end;
}
.macro-event-empty {
padding: 14px;
text-align: center;
color: var(--muted);
font-size: 12px;
border: 1px dashed var(--border-soft);
border-radius: var(--radius);
}
.host-status-panel {
margin: 0 0 12px;
border-radius: var(--radius);
+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);
+37 -2
View File
@@ -15,7 +15,7 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" media="print" onload="this.media='all'" />
<noscript><link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" /></noscript>
<link rel="stylesheet" href="/assets/app.css?v=20260614-instance-nav-v2" />
<link rel="stylesheet" href="/assets/app.css?v=20260618-macro-calendar" />
<link rel="stylesheet" href="/assets/account_risk_badge.css?v=1" />
<link rel="stylesheet" href="/assets/dashboard.css?v=20260612-dash-monitor-count" />
</head>
@@ -115,6 +115,12 @@
</div>
</div>
</details>
<div id="monitor-macro-banner" class="monitor-macro-banner hidden" aria-live="polite">
<div class="monitor-macro-banner-inner">
<span class="monitor-macro-badge">宏观风控</span>
<span id="monitor-macro-banner-text" class="monitor-macro-text"></span>
</div>
</div>
<div id="monitor-alert-summary" class="monitor-alert-summary hidden" aria-live="polite"></div>
<div class="toolbar">
<button type="button" id="btn-monitor-refresh" class="primary">立即刷新</button>
@@ -615,6 +621,35 @@
</label>
<p class="settings-display-hint">保存至 hub_settings.json,换浏览器同样生效。关闭导航后对应页面将不可从顶栏进入。</p>
</div>
<div class="settings-macro-panel card">
<h3 class="settings-display-title">宏观关键数据(风控前置)</h3>
<p class="settings-display-hint">
手动录入 FOMC / CPI / 就业数据发布时间(北京时间)。监控区在发布前后各 1 小时提示风险:有仓注意仓位,无仓建议等待。仅提醒,不拦截下单。
</p>
<form id="macro-event-form" class="macro-event-form">
<label class="macro-event-field">
<span>数据名称</span>
<select id="macro-event-type" required>
<option value="fomc">FOMC 联邦基金利率</option>
<option value="cpi">美国 CPI 通胀</option>
<option value="employment">就业与劳工数据</option>
</select>
</label>
<label class="macro-event-field">
<span>发布时间(北京)</span>
<input id="macro-event-at" type="datetime-local" required />
</label>
<label class="macro-event-field macro-event-field-wide">
<span>备注(可选)</span>
<input id="macro-event-note" type="text" maxlength="500" placeholder="如:仅关注核心 CPI" autocomplete="off" />
</label>
<div class="macro-event-actions">
<button type="submit" id="macro-event-submit" class="primary">添加</button>
<button type="button" id="macro-event-cancel" class="ghost hidden">取消编辑</button>
</div>
</form>
<div id="macro-event-list" class="macro-event-list"></div>
</div>
<div class="toolbar">
<button type="button" id="btn-settings-save" class="primary">保存设置</button>
<button type="button" id="btn-settings-add">添加交易所</button>
@@ -654,6 +689,6 @@
<script src="/assets/dashboard.js?v=20260612-dash-monitor-count"></script>
<script src="/assets/ai_review_render.js?v=3"></script>
<script src="/assets/time_close_ui.js?v=2"></script>
<script src="/assets/app.js?v=20260614-instance-nav-v2"></script>
<script src="/assets/app.js?v=20260618-macro-calendar"></script>
</body>
</html>