From e470c5952f1b13ff3a1dabd0978633270bb45817 Mon Sep 17 00:00:00 2001 From: dekun Date: Thu, 18 Jun 2026 11:52:30 +0800 Subject: [PATCH] feat(hub): add macro calendar for pre-release risk alerts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Manual FOMC/CPI/employment entries in settings drive ±1h monitor banners without touching exchange instances. Co-authored-by: Cursor --- docs/macro-calendar.md | 83 +++++++ hub_macro_calendar_lib.py | 311 +++++++++++++++++++++++++++ manual_trading_hub/hub.py | 81 ++++++- manual_trading_hub/static/app.css | 133 ++++++++++++ manual_trading_hub/static/app.js | 215 ++++++++++++++++++ manual_trading_hub/static/index.html | 39 +++- tests/test_hub_macro_calendar_lib.py | 73 +++++++ 7 files changed, 932 insertions(+), 3 deletions(-) create mode 100644 docs/macro-calendar.md create mode 100644 hub_macro_calendar_lib.py create mode 100644 tests/test_hub_macro_calendar_lib.py diff --git a/docs/macro-calendar.md b/docs/macro-calendar.md new file mode 100644 index 0000000..72107e3 --- /dev/null +++ b/docs/macro-calendar.md @@ -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. 中控 **系统设置 → 宏观关键数据** 录入 1~3 条 +3. 到点前后监控区顶栏出现 **宏观风控** 横幅;无操作则窗口结束后自动消失 + +## 与账户风控的关系 + +| 模块 | 时机 | 作用 | +|------|------|------| +| 宏观日历 | **事前** | 已知高波动窗口,提醒等待或管仓 | +| 账户冷静期/日冻结 | **事后** | 用户主动平仓后的惩罚性限制 | + +宏观提醒 **不触发** 冷静期、不计入手动平仓次数。 diff --git a/hub_macro_calendar_lib.py b/hub_macro_calendar_lib.py new file mode 100644 index 0000000..45aba3e --- /dev/null +++ b/hub_macro_calendar_lib.py @@ -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),建议等待,避免新开仓" diff --git a/manual_trading_hub/hub.py b/manual_trading_hub/hub.py index 4eac354..ce2deac 100644 --- a/manual_trading_hub/hub.py +++ b/manual_trading_hub/hub.py @@ -60,6 +60,16 @@ from hub_symbol_archive_lib import ( update_review_quote, 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 load_hub_dotenv() @@ -2188,6 +2198,75 @@ def api_archive_quote_delete(quote_id: int): 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") def api_archive_detail(exchange_key: str = "", symbol: str = ""): ex_k = (exchange_key or "").strip().lower() @@ -2307,7 +2386,7 @@ def api_ping(): "service": "manual-trading-hub", "build": HUB_BUILD, "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_version": board_store.version, "board_aggregating": board_store.aggregating, diff --git a/manual_trading_hub/static/app.css b/manual_trading_hub/static/app.css index efd6c72..e3465fb 100644 --- a/manual_trading_hub/static/app.css +++ b/manual_trading_hub/static/app.css @@ -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); diff --git a/manual_trading_hub/static/app.js b/manual_trading_hub/static/app.js index 0923853..f7689f6 100644 --- a/manual_trading_hub/static/app.js +++ b/manual_trading_hub/static/app.js @@ -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 = '
暂无已录入的关键数据。请在上方添加 FOMC / CPI / 就业发布时间。
'; + 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 ? `
${esc(ev.note)}
` : ""; + return `
+
+
${esc(ev.event_type_label || ev.event_type)}
+ ${note} +
+
${esc(ev.event_at || "")}
+
${active ? "窗口内" : "待触发"} · ±1h
+
+ + +
+
`; + }) + .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 = `
${esc(String(e))}
`; + } + } + + 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); diff --git a/manual_trading_hub/static/index.html b/manual_trading_hub/static/index.html index 38933ed..d6c1b6d 100644 --- a/manual_trading_hub/static/index.html +++ b/manual_trading_hub/static/index.html @@ -15,7 +15,7 @@ - + @@ -115,6 +115,12 @@ +
@@ -615,6 +621,35 @@

保存至 hub_settings.json,换浏览器同样生效。关闭导航后对应页面将不可从顶栏进入。

+
+

宏观关键数据(风控前置)

+

+ 手动录入 FOMC / CPI / 就业数据发布时间(北京时间)。监控区在发布前后各 1 小时提示风险:有仓注意仓位,无仓建议等待。仅提醒,不拦截下单。 +

+
+ + + +
+ + +
+
+
+
@@ -654,6 +689,6 @@ - + diff --git a/tests/test_hub_macro_calendar_lib.py b/tests/test_hub_macro_calendar_lib.py new file mode 100644 index 0000000..33815be --- /dev/null +++ b/tests/test_hub_macro_calendar_lib.py @@ -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()