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