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
+83
View File
@@ -0,0 +1,83 @@
# 宏观关键数据 · 风控前置
中控 **系统设置** 手动录入 FOMC / CPI / 就业数据发布时间,在 **监控区** 发布前后各 1 小时给出风险提示。
**不看公布结果、不解读数据**,仅作波动窗口前的行为提醒;**不拦截下单**(与账户冷静期/日冻结独立)。
## 支持的数据类型
| 类型 ID | 显示名称 |
|---------|----------|
| `fomc` | FOMC 联邦基金利率 |
| `cpi` | 美国 CPI 通胀 |
| `employment` | 就业与劳工数据 |
每项在设置中 **名称下拉三选一**,**发布时间** 手动输入(北京时间,精确到分钟)。FOMC 只录 **一条**(决议公布时刻即可)。
## 风险窗口
- 默认:**发布时间 ±1 小时**
- 发布前 **30 分钟内**:文案加强为「即将发布」
- 窗口结束后横幅自动消失;设置列表中过期记录逐步不再展示
环境变量(可选):
```env
HUB_MACRO_WINDOW_BEFORE_SEC=3600
HUB_MACRO_WINDOW_AFTER_SEC=3600
HUB_MACRO_IMMINENT_BEFORE_SEC=1800
HUB_MACRO_LIST_FUTURE_DAYS=60
```
## 监控区提示文案
读取当前监控板:**任意交易所有持仓 = 有仓**,否则 = 无仓。
| 场景 | 提示要点 |
|------|----------|
| 无仓 · 窗口内 | 建议等待,避免新开仓 |
| 有仓 · 窗口内 | 注意仓位,勿加仓,检查止损/减仓 |
| 即将发布(30 分钟内) | 在上述基础上标注剩余分钟数 |
## 存储
- SQLite`manual_trading_hub/data/hub_macro_calendar.db`
- 可覆盖:`HUB_MACRO_CALENDAR_DB_PATH`
`macro_events``event_type`, `event_at_ms`, `note`, `created_at_ms`, `updated_at_ms`
同类型 + 同一发布时间不可重复录入。
## API(均需中控登录)
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/macro-calendar/meta` | 类型列表与窗口说明 |
| GET | `/api/macro-calendar/events` | 设置页列表 |
| GET | `/api/macro-calendar/active` | 当前处于窗口内的事件(监控横幅) |
| POST | `/api/macro-calendar/events` | 新增 |
| PATCH | `/api/macro-calendar/events/{id}` | 更新 |
| DELETE | `/api/macro-calendar/events/{id}` | 删除 |
请求体示例:
```json
{
"event_type": "cpi",
"event_at": "2026-06-18 20:30",
"note": "可选备注"
}
```
## 使用习惯
1. 每月在金十/日历查看 **FOMC、CPI、非农** 公布时间
2. 中控 **系统设置 → 宏观关键数据** 录入 13 条
3. 到点前后监控区顶栏出现 **宏观风控** 横幅;无操作则窗口结束后自动消失
## 与账户风控的关系
| 模块 | 时机 | 作用 |
|------|------|------|
| 宏观日历 | **事前** | 已知高波动窗口,提醒等待或管仓 |
| 账户冷静期/日冻结 | **事后** | 用户主动平仓后的惩罚性限制 |
宏观提醒 **不触发** 冷静期、不计入手动平仓次数。
+311
View File
@@ -0,0 +1,311 @@
"""中控宏观关键数据日历:手动录入 FOMC / CPI / 非农档发布时间,±1h 风控前置窗口。"""
from __future__ import annotations
import os
import sqlite3
import time
from datetime import datetime
from pathlib import Path
from typing import Any
from zoneinfo import ZoneInfo
from hub_symbol_archive_lib import parse_wall_clock_ms
DISPLAY_TZ = ZoneInfo(os.getenv("APP_TIMEZONE", "Asia/Shanghai"))
MACRO_EVENT_TYPES = ("fomc", "cpi", "employment")
MACRO_EVENT_LABELS: dict[str, str] = {
"fomc": "FOMC 联邦基金利率",
"cpi": "美国 CPI 通胀",
"employment": "就业与劳工数据",
}
WINDOW_BEFORE_MS = int(os.getenv("HUB_MACRO_WINDOW_BEFORE_SEC", str(3600))) * 1000
WINDOW_AFTER_MS = int(os.getenv("HUB_MACRO_WINDOW_AFTER_SEC", str(3600))) * 1000
IMMINENT_BEFORE_MS = int(os.getenv("HUB_MACRO_IMMINENT_BEFORE_SEC", str(1800))) * 1000
LIST_FUTURE_DAYS = int(os.getenv("HUB_MACRO_LIST_FUTURE_DAYS", "60"))
def default_db_path() -> Path:
raw = (os.getenv("HUB_MACRO_CALENDAR_DB_PATH") or "").strip()
if raw:
return Path(raw)
hub_dir = Path(__file__).resolve().parent / "manual_trading_hub" / "data"
hub_dir.mkdir(parents=True, exist_ok=True)
return hub_dir / "hub_macro_calendar.db"
def _connect(db_path: Path | None = None) -> sqlite3.Connection:
path = db_path or default_db_path()
path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(path), timeout=30, isolation_level=None)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA synchronous=NORMAL")
return conn
def init_db(db_path: Path | None = None) -> None:
conn = _connect(db_path)
try:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS macro_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_type TEXT NOT NULL,
event_at_ms INTEGER NOT NULL,
note TEXT NOT NULL DEFAULT '',
created_at_ms INTEGER NOT NULL,
updated_at_ms INTEGER NOT NULL
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_macro_events_at ON macro_events(event_at_ms)"
)
finally:
conn.close()
def normalize_event_type(raw: str) -> str:
key = (raw or "").strip().lower()
if key not in MACRO_EVENT_TYPES:
raise ValueError(f"事件类型须为: {', '.join(MACRO_EVENT_LABELS.values())}")
return key
def parse_event_at_ms(raw: Any) -> int:
ms = parse_wall_clock_ms(raw, tz=DISPLAY_TZ)
if ms is None:
raise ValueError("发布时间格式错误,请使用 YYYY-MM-DD HH:MM 或 YYYY-MM-DDTHH:MM")
return int(ms)
def format_event_at(ms: int) -> str:
dt = datetime.fromtimestamp(ms / 1000, tz=DISPLAY_TZ)
return dt.strftime("%Y-%m-%d %H:%M")
def _row_to_dict(row: sqlite3.Row) -> dict[str, Any]:
ms = int(row["event_at_ms"])
et = str(row["event_type"])
return {
"id": int(row["id"]),
"event_type": et,
"event_type_label": MACRO_EVENT_LABELS.get(et, et),
"event_at_ms": ms,
"event_at": format_event_at(ms),
"note": str(row["note"] or ""),
"created_at_ms": int(row["created_at_ms"]),
"updated_at_ms": int(row["updated_at_ms"]),
}
def _window_bounds(event_at_ms: int) -> tuple[int, int]:
start = int(event_at_ms) - WINDOW_BEFORE_MS
end = int(event_at_ms) + WINDOW_AFTER_MS
return start, end
def enrich_alert(row: dict[str, Any], now_ms: int | None = None) -> dict[str, Any] | None:
now = int(now_ms if now_ms is not None else time.time() * 1000)
event_at_ms = int(row["event_at_ms"])
window_start, window_end = _window_bounds(event_at_ms)
if now < window_start or now > window_end:
return None
imminent = now >= (event_at_ms - IMMINENT_BEFORE_MS) and now <= window_end
mins_to_event = max(0, int((event_at_ms - now) / 60000))
mins_from_event = max(0, int((now - event_at_ms) / 60000))
return {
**row,
"window_start_ms": window_start,
"window_end_ms": window_end,
"window_start": format_event_at(window_start),
"window_end": format_event_at(window_end),
"phase": "imminent" if imminent else "window",
"phase_label": "即将发布" if imminent and now < event_at_ms else "高波动窗口",
"minutes_to_event": mins_to_event if now < event_at_ms else 0,
"minutes_from_event": mins_from_event if now >= event_at_ms else 0,
}
def list_events(
*,
now_ms: int | None = None,
include_expired_hours: int = 24,
db_path: Path | None = None,
) -> list[dict[str, Any]]:
init_db(db_path)
now = int(now_ms if now_ms is not None else time.time() * 1000)
horizon = now + LIST_FUTURE_DAYS * 86400 * 1000
expired_cutoff = now - max(0, int(include_expired_hours)) * 3600 * 1000 - WINDOW_AFTER_MS
conn = _connect(db_path)
try:
rows = conn.execute(
"""
SELECT * FROM macro_events
WHERE event_at_ms >= ? AND event_at_ms <= ?
ORDER BY event_at_ms ASC, id ASC
""",
(expired_cutoff, horizon),
).fetchall()
return [_row_to_dict(r) for r in rows]
finally:
conn.close()
def get_event(event_id: int, db_path: Path | None = None) -> dict[str, Any] | None:
init_db(db_path)
conn = _connect(db_path)
try:
row = conn.execute("SELECT * FROM macro_events WHERE id=?", (int(event_id),)).fetchone()
return _row_to_dict(row) if row else None
finally:
conn.close()
def _assert_no_duplicate(
conn: sqlite3.Connection,
event_type: str,
event_at_ms: int,
*,
exclude_id: int | None = None,
) -> None:
if exclude_id is None:
row = conn.execute(
"SELECT id FROM macro_events WHERE event_type=? AND event_at_ms=? LIMIT 1",
(event_type, int(event_at_ms)),
).fetchone()
else:
row = conn.execute(
"""
SELECT id FROM macro_events
WHERE event_type=? AND event_at_ms=? AND id<>?
LIMIT 1
""",
(event_type, int(event_at_ms), int(exclude_id)),
).fetchone()
if row:
raise ValueError("同类型、同发布时间的记录已存在")
def create_event(
event_type: str,
event_at: Any,
*,
note: str = "",
db_path: Path | None = None,
) -> dict[str, Any]:
init_db(db_path)
et = normalize_event_type(event_type)
event_at_ms = parse_event_at_ms(event_at)
note_s = str(note or "").strip()[:500]
now_ms = int(time.time() * 1000)
conn = _connect(db_path)
try:
_assert_no_duplicate(conn, et, event_at_ms)
cur = conn.execute(
"""
INSERT INTO macro_events (event_type, event_at_ms, note, created_at_ms, updated_at_ms)
VALUES (?, ?, ?, ?, ?)
""",
(et, event_at_ms, note_s, now_ms, now_ms),
)
eid = int(cur.lastrowid)
finally:
conn.close()
row = get_event(eid, db_path=db_path)
assert row is not None
return row
def update_event(
event_id: int,
*,
event_type: str | None = None,
event_at: Any | None = None,
note: str | None = None,
db_path: Path | None = None,
) -> dict[str, Any] | None:
init_db(db_path)
existing = get_event(event_id, db_path=db_path)
if not existing:
return None
et = normalize_event_type(event_type if event_type is not None else existing["event_type"])
event_at_ms = (
parse_event_at_ms(event_at) if event_at is not None else int(existing["event_at_ms"])
)
note_s = existing["note"] if note is None else str(note or "").strip()[:500]
now_ms = int(time.time() * 1000)
conn = _connect(db_path)
try:
_assert_no_duplicate(conn, et, event_at_ms, exclude_id=int(event_id))
conn.execute(
"""
UPDATE macro_events
SET event_type=?, event_at_ms=?, note=?, updated_at_ms=?
WHERE id=?
""",
(et, event_at_ms, note_s, now_ms, int(event_id)),
)
finally:
conn.close()
return get_event(event_id, db_path=db_path)
def delete_event(event_id: int, db_path: Path | None = None) -> bool:
init_db(db_path)
conn = _connect(db_path)
try:
cur = conn.execute("DELETE FROM macro_events WHERE id=?", (int(event_id),))
return cur.rowcount > 0
finally:
conn.close()
def list_active_alerts(
now_ms: int | None = None,
db_path: Path | None = None,
) -> list[dict[str, Any]]:
now = int(now_ms if now_ms is not None else time.time() * 1000)
lookback = now - WINDOW_BEFORE_MS - IMMINENT_BEFORE_MS
lookahead = now + WINDOW_AFTER_MS
init_db(db_path)
conn = _connect(db_path)
try:
rows = conn.execute(
"""
SELECT * FROM macro_events
WHERE event_at_ms >= ? AND event_at_ms <= ?
ORDER BY event_at_ms ASC, id ASC
""",
(lookback, lookahead),
).fetchall()
finally:
conn.close()
alerts: list[dict[str, Any]] = []
for row in rows:
item = enrich_alert(_row_to_dict(row), now_ms=now)
if item:
alerts.append(item)
return alerts
def build_banner_message(alert: dict[str, Any], *, has_positions: bool) -> str:
label = alert.get("event_type_label") or alert.get("event_type") or "宏观数据"
phase = alert.get("phase") or "window"
if has_positions:
if phase == "imminent" and int(alert.get("minutes_to_event") or 0) > 0:
return (
f"{label}」即将发布(约 {alert['minutes_to_event']} 分钟),"
"注意仓位风险:勿加仓,检查止损/减仓"
)
return f"{label}」高波动窗口(±1h),注意仓位风险:勿加仓,检查止损/减仓"
if phase == "imminent" and int(alert.get("minutes_to_event") or 0) > 0:
return (
f"{label}」即将发布(约 {alert['minutes_to_event']} 分钟),"
"建议等待,避免新开仓"
)
return f"{label}」高波动窗口(±1h),建议等待,避免新开仓"
+80 -1
View File
@@ -60,6 +60,16 @@ from hub_symbol_archive_lib import (
update_review_quote, update_review_quote,
upsert_trade_overlay, upsert_trade_overlay,
) )
from hub_macro_calendar_lib import (
MACRO_EVENT_LABELS,
MACRO_EVENT_TYPES,
create_event as create_macro_event,
delete_event as delete_macro_event,
init_db as init_macro_calendar_db,
list_active_alerts,
list_events as list_macro_events,
update_event as update_macro_event,
)
from env_load import load_hub_dotenv from env_load import load_hub_dotenv
load_hub_dotenv() load_hub_dotenv()
@@ -2188,6 +2198,75 @@ def api_archive_quote_delete(quote_id: int):
return {"ok": True, "id": int(quote_id)} return {"ok": True, "id": int(quote_id)}
class MacroEventBody(BaseModel):
event_type: str = ""
event_at: str = ""
note: str = ""
@app.get("/api/macro-calendar/meta")
def api_macro_calendar_meta():
init_macro_calendar_db()
return {
"ok": True,
"event_types": [
{"id": k, "label": MACRO_EVENT_LABELS[k]} for k in MACRO_EVENT_TYPES
],
"window_before_minutes": 60,
"window_after_minutes": 60,
"timezone": "Asia/Shanghai",
}
@app.get("/api/macro-calendar/events")
def api_macro_calendar_events():
init_macro_calendar_db()
rows = list_macro_events()
return {"ok": True, "events": rows, "count": len(rows)}
@app.get("/api/macro-calendar/active")
def api_macro_calendar_active():
init_macro_calendar_db()
alerts = list_active_alerts()
return {"ok": True, "alerts": alerts, "count": len(alerts)}
@app.post("/api/macro-calendar/events")
def api_macro_calendar_create(body: MacroEventBody = Body(...)):
init_macro_calendar_db()
try:
row = create_macro_event(body.event_type, body.event_at, note=body.note)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
return {"ok": True, "event": row}
@app.patch("/api/macro-calendar/events/{event_id}")
def api_macro_calendar_update(event_id: int, body: MacroEventBody = Body(...)):
init_macro_calendar_db()
try:
row = update_macro_event(
int(event_id),
event_type=body.event_type or None,
event_at=body.event_at or None,
note=body.note,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
if not row:
raise HTTPException(status_code=404, detail="记录不存在")
return {"ok": True, "event": row}
@app.delete("/api/macro-calendar/events/{event_id}")
def api_macro_calendar_delete(event_id: int):
init_macro_calendar_db()
if not delete_macro_event(int(event_id)):
raise HTTPException(status_code=404, detail="记录不存在")
return {"ok": True, "id": int(event_id)}
@app.get("/api/archive/detail") @app.get("/api/archive/detail")
def api_archive_detail(exchange_key: str = "", symbol: str = ""): def api_archive_detail(exchange_key: str = "", symbol: str = ""):
ex_k = (exchange_key or "").strip().lower() ex_k = (exchange_key or "").strip().lower()
@@ -2307,7 +2386,7 @@ def api_ping():
"service": "manual-trading-hub", "service": "manual-trading-hub",
"build": HUB_BUILD, "build": HUB_BUILD,
"trade_ui": False, "trade_ui": False,
"features": ["monitor", "settings", "auth", "board_sse", "dashboard_sse", "archive", "dashboard", "funds"], "features": ["monitor", "settings", "auth", "board_sse", "dashboard_sse", "archive", "dashboard", "funds", "macro_calendar"],
"board_poll_interval_sec": HUB_BOARD_POLL_INTERVAL, "board_poll_interval_sec": HUB_BOARD_POLL_INTERVAL,
"board_version": board_store.version, "board_version": board_store.version,
"board_aggregating": board_store.aggregating, "board_aggregating": board_store.aggregating,
+133
View File
@@ -651,6 +651,139 @@ button:disabled {
color: var(--muted); 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 { .host-status-panel {
margin: 0 0 12px; margin: 0 0 12px;
border-radius: var(--radius); border-radius: var(--radius);
+215
View File
@@ -1075,6 +1075,7 @@
function stopMonitorPoll() { function stopMonitorPoll() {
closeMonitorBoardStream(); closeMonitorBoardStream();
stopHostStatusPoll(); stopHostStatusPoll();
stopMacroBannerPoll();
if (sseReconnectTimer) { if (sseReconnectTimer) {
clearTimeout(sseReconnectTimer); clearTimeout(sseReconnectTimer);
sseReconnectTimer = null; sseReconnectTimer = null;
@@ -1210,14 +1211,86 @@
: ""; : "";
} }
updateMonitorAlertSummary(rows || []); updateMonitorAlertSummary(rows || []);
void refreshMacroRiskBanner(rows || []);
renderMonitorGrid(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() { function startMonitorPoll() {
const hadCache = restoreMonitorBoardFromCache(); const hadCache = restoreMonitorBoardFromCache();
void fetchMonitorBoardSnapshot({ showLoading: !hadCache }); void fetchMonitorBoardSnapshot({ showLoading: !hadCache });
connectMonitorBoardStream(); connectMonitorBoardStream();
startHostStatusPoll(); startHostStatusPoll();
startMacroBannerPoll();
} }
async function loadSettings() { 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() { function loadSettingsUI() {
loadSettingsMetaLine(); loadSettingsMetaLine();
initMacroCalendarSettings();
loadMacroCalendarUI();
loadSettings().then((data) => { loadSettings().then((data) => {
syncDisplayPrefsUI(data); syncDisplayPrefsUI(data);
renderSettingsList(data); renderSettingsList(data);
+37 -2
View File
@@ -15,7 +15,7 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <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'" /> <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> <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/account_risk_badge.css?v=1" />
<link rel="stylesheet" href="/assets/dashboard.css?v=20260612-dash-monitor-count" /> <link rel="stylesheet" href="/assets/dashboard.css?v=20260612-dash-monitor-count" />
</head> </head>
@@ -115,6 +115,12 @@
</div> </div>
</div> </div>
</details> </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 id="monitor-alert-summary" class="monitor-alert-summary hidden" aria-live="polite"></div>
<div class="toolbar"> <div class="toolbar">
<button type="button" id="btn-monitor-refresh" class="primary">立即刷新</button> <button type="button" id="btn-monitor-refresh" class="primary">立即刷新</button>
@@ -615,6 +621,35 @@
</label> </label>
<p class="settings-display-hint">保存至 hub_settings.json,换浏览器同样生效。关闭导航后对应页面将不可从顶栏进入。</p> <p class="settings-display-hint">保存至 hub_settings.json,换浏览器同样生效。关闭导航后对应页面将不可从顶栏进入。</p>
</div> </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"> <div class="toolbar">
<button type="button" id="btn-settings-save" class="primary">保存设置</button> <button type="button" id="btn-settings-save" class="primary">保存设置</button>
<button type="button" id="btn-settings-add">添加交易所</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/dashboard.js?v=20260612-dash-monitor-count"></script>
<script src="/assets/ai_review_render.js?v=3"></script> <script src="/assets/ai_review_render.js?v=3"></script>
<script src="/assets/time_close_ui.js?v=2"></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> </body>
</html> </html>
+73
View File
@@ -0,0 +1,73 @@
import os
import tempfile
import unittest
from pathlib import Path
from unittest import mock
from hub_macro_calendar_lib import (
build_banner_message,
create_event,
delete_event,
enrich_alert,
init_db,
list_active_alerts,
list_events,
update_event,
)
class HubMacroCalendarLibTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db_path = Path(self.tmp.name) / "macro.db"
init_db(self.db_path)
def tearDown(self):
self.tmp.cleanup()
def test_create_and_list(self):
row = create_event("cpi", "2026-06-18 20:30", note="核心CPI", db_path=self.db_path)
self.assertEqual(row["event_type"], "cpi")
self.assertEqual(row["event_at"], "2026-06-18 20:30")
rows = list_events(now_ms=row["event_at_ms"] - 86400000, db_path=self.db_path)
self.assertEqual(len(rows), 1)
def test_duplicate_rejected(self):
create_event("fomc", "2026-07-01 02:00", db_path=self.db_path)
with self.assertRaises(ValueError):
create_event("fomc", "2026-07-01 02:00", db_path=self.db_path)
def test_active_window_and_messages(self):
row = create_event("employment", "2026-06-18 20:30", db_path=self.db_path)
t0 = int(row["event_at_ms"])
inside = enrich_alert(row, now_ms=t0 - 30 * 60 * 1000)
self.assertIsNotNone(inside)
self.assertEqual(inside["phase"], "imminent")
outside = enrich_alert(row, now_ms=t0 - 2 * 3600 * 1000)
self.assertIsNone(outside)
alerts = list_active_alerts(now_ms=t0 + 15 * 60 * 1000, db_path=self.db_path)
self.assertEqual(len(alerts), 1)
msg_pos = build_banner_message(alerts[0], has_positions=True)
msg_flat = build_banner_message(alerts[0], has_positions=False)
self.assertIn("注意仓位风险", msg_pos)
self.assertIn("建议等待", msg_flat)
def test_update_and_delete(self):
row = create_event("cpi", "2026-06-18 20:30", db_path=self.db_path)
updated = update_event(
row["id"],
event_at="2026-06-18 21:00",
note="修正时间",
db_path=self.db_path,
)
self.assertEqual(updated["event_at"], "2026-06-18 21:00")
self.assertTrue(delete_event(row["id"], db_path=self.db_path))
self.assertEqual(len(list_events(now_ms=updated["event_at_ms"], db_path=self.db_path)), 0)
def test_invalid_type(self):
with self.assertRaises(ValueError):
create_event("nfp", "2026-06-18 20:30", db_path=self.db_path)
if __name__ == "__main__":
unittest.main()