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:
@@ -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. 到点前后监控区顶栏出现 **宏观风控** 横幅;无操作则窗口结束后自动消失
|
||||||
|
|
||||||
|
## 与账户风控的关系
|
||||||
|
|
||||||
|
| 模块 | 时机 | 作用 |
|
||||||
|
|------|------|------|
|
||||||
|
| 宏观日历 | **事前** | 已知高波动窗口,提醒等待或管仓 |
|
||||||
|
| 账户冷静期/日冻结 | **事后** | 用户主动平仓后的惩罚性限制 |
|
||||||
|
|
||||||
|
宏观提醒 **不触发** 冷静期、不计入手动平仓次数。
|
||||||
@@ -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),建议等待,避免新开仓"
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user