Skip to content

Commit 0f0a482

Browse files
committed
docs: add alert center P0 baseline contracts
1 parent a75a0c5 commit 0f0a482

7 files changed

Lines changed: 387 additions & 0 deletions

File tree

docs/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
3939
- [修复] 修正分析报告 API 构建策略点位时数值字段未归一为字符串的问题,避免策略价格触发响应 DTO 类型校验失败。
4040
- [修复] Docker 启动入口自动修复 `data` / `logs` / `reports` 挂载目录权限并降权运行,文档化的 Compose `exec` 手动命令显式使用 `dsa` 用户,避免普通部署需要手动 `chown` / `chmod`
4141
- [修复] Web 首页大盘复盘结果改由主内容滚动区承载,避免 loading 切换到长结果后下方报告区域被截断或无法继续滚动。
42+
- [文档] 新增告警中心专题文档(docs/alerts.md),说明 EventMonitor 基线、legacy 规则契约和 Phase 边界。
4243

4344
## [3.16.0] - 2026-05-10
4445

docs/INDEX.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
| --- | --- |
4141
| [Bot 命令与接入](bot-command.md) | Bot 命令、Webhook、平台接入和回调说明 |
4242
| [Bot 平台配置](bot/) | 飞书、钉钉、Discord 等 Bot 配置截图和补充说明 |
43+
| [实时告警中心](alerts.md) | EventMonitor 基线、告警契约、存储评估和 Phase 边界 |
4344
| [图片识别 Prompt](image-extract-prompt.md) | 图片识别股票信息的 Prompt 与使用边界 |
4445
| [OpenClaw Skill 集成](openclaw-skill-integration.md) | OpenClaw / Skill 外部集成说明 |
4546

docs/INDEX_EN.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ This is the entry point for project documentation. The README covers the project
4242
| --- | --- |
4343
| [Bot Commands (EN)](bot-command_EN.md) | Bot commands, webhooks, platform integration, and callback behavior |
4444
| [Bot Platform Docs](bot/) <sub><sub>![P2 Badge](https://img.shields.io/badge/P2-yellow?style=flat)</sub></sub> (Chinese-only) | Feishu, DingTalk, Discord, and related Bot configuration screenshots and notes |
45+
| [Real-Time Alert Center](alerts.md) <sub><sub>![P2 Badge](https://img.shields.io/badge/P2-yellow?style=flat)</sub></sub> (Chinese-only) | EventMonitor baseline, alert contracts, storage evaluation, and phase boundaries |
4546
| [Image Extraction Prompt](image-extract-prompt.md) <sub><sub>![P2 Badge](https://img.shields.io/badge/P2-yellow?style=flat)</sub></sub> (Chinese-only) | Prompt and boundaries for extracting stock information from images |
4647
| [OpenClaw Skill Integration](openclaw-skill-integration.md) <sub><sub>![P2 Badge](https://img.shields.io/badge/P2-yellow?style=flat)</sub></sub> (Chinese-only) | OpenClaw / Skill external integration notes |
4748

docs/alerts.md

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
# 实时告警中心
2+
3+
本文档记录 Issue #1202 P0 的告警中心基线、数据契约、存储评估和兼容边界。P0 只定义后续实现可以复用的契约,不新增 API、Web 页面、数据库表、触发历史写入、冷却执行或规则迁移。
4+
5+
## 当前基线
6+
7+
当前运行时告警由 `src/agent/events.py` 中的 `EventMonitor` 提供,并通过 schedule 模式后台轮询执行。
8+
9+
- 配置入口:`AGENT_EVENT_MONITOR_ENABLED``AGENT_EVENT_MONITOR_INTERVAL_MINUTES``AGENT_EVENT_ALERT_RULES_JSON`
10+
- 运行入口:`main.py` 在 schedule 模式中调用 `build_event_monitor_from_config()`,并注册 `agent_event_monitor` 后台任务。
11+
- 通知投递:触发后复用 `NotificationService.send(..., route_type="alert")`,继续遵守通知网关的 alert 路由配置。
12+
- Web/System 配置校验:`src/services/system_config_service.py` 会对 `AGENT_EVENT_ALERT_RULES_JSON` 做 JSON 与规则语义校验。
13+
14+
当前 runtime 支持三类规则:
15+
16+
| `alert_type` | 方向字段 | 阈值字段 | 当前语义 |
17+
| --- | --- | --- | --- |
18+
| `price_cross` | `direction`: `above` / `below` | `price` | 实时价格上破或下破固定价格 |
19+
| `price_change_percent` | `direction`: `up` / `down` | `change_pct` | 实时涨跌幅达到指定百分比 |
20+
| `volume_spike` | - | `multiplier` | 最新成交量超过近 20 日均量的指定倍数 |
21+
22+
`sentiment_shift``risk_flag``custom` 等类型只作为未来扩展占位;当前运行时不接受这些类型作为可执行规则。
23+
24+
## Legacy 配置兼容
25+
26+
P0 保留 `AGENT_EVENT_ALERT_RULES_JSON` 作为唯一运行时规则来源,不自动迁移、删除、覆盖或改写用户已有 `.env` / Web 配置。
27+
28+
- 空字符串或空数组表示未配置规则;启用 EventMonitor 但没有有效规则时,schedule 模式不会注册后台告警任务。
29+
- Web/System 配置保存时执行严格校验,JSON 无效、字段缺失、方向非法、阈值非法或 unsupported rule type 都应返回配置错误。
30+
- 运行时加载时允许跳过单条无效规则,剩余有效规则继续工作,避免单条配置破坏整个 schedule 进程。
31+
- 当前规则触发后会在进程内标记为 `triggered`,这不是告警中心冷却模型,也不提供跨进程或重启后的触发历史。
32+
33+
## 数据契约
34+
35+
以下契约用于后续 P1+ API、worker、Web 和存储实现对齐。P0 只定义字段和语义边界,不代表当前已经存在这些持久化实体。
36+
37+
### `alert_rule`
38+
39+
可管理的告警规则。
40+
41+
| 字段 | 说明 |
42+
| --- | --- |
43+
| `id` | 规则 ID;legacy JSON 规则在 P0 中没有持久化 ID |
44+
| `name` | 用户可读名称;没有提供时可由规则类型和目标生成 |
45+
| `target_scope` | 目标范围,例如 single symbol、watchlist、portfolio、market |
46+
| `target` | 目标标的或目标引用,例如股票代码、watchlist ID、portfolio ID |
47+
| `alert_type` | 规则类型;P1 初始只允许 `price_cross``price_change_percent``volume_spike` |
48+
| `parameters` | 规则参数,例如 `direction``price``change_pct``multiplier` |
49+
| `severity` | 告警等级,例如 info、warning、critical |
50+
| `enabled` | 是否启用 |
51+
| `cooldown_policy` | 冷却策略;P0 只定义字段,P4 才实现执行语义 |
52+
| `notification_policy` | 通知策略;默认复用 `NotificationService` 的 alert 路由 |
53+
| `source` | 创建来源,例如 legacy_env、web、api、import |
54+
| `created_at` / `updated_at` | 创建和更新时间 |
55+
56+
### `alert_trigger`
57+
58+
一次真实或可记录的规则触发。
59+
60+
| 字段 | 说明 |
61+
| --- | --- |
62+
| `id` | 触发记录 ID |
63+
| `rule_id` | 对应规则 ID;legacy env 规则可记录临时引用 |
64+
| `target` | 实际触发目标 |
65+
| `observed_value` | 观察值,例如现价、涨跌幅、成交量倍数 |
66+
| `threshold` | 触发阈值 |
67+
| `reason` | 可读触发原因 |
68+
| `data_source` | 数据源或 provider |
69+
| `data_timestamp` | 数据时间;缺失时不得伪造为当前时间 |
70+
| `triggered_at` | 触发时间 |
71+
| `status` | 触发状态,例如 triggered、skipped、degraded、failed |
72+
| `diagnostics` | 脱敏后的诊断信息 |
73+
74+
### `alert_notification`
75+
76+
一次触发对应的通知尝试。
77+
78+
| 字段 | 说明 |
79+
| --- | --- |
80+
| `id` | 通知尝试 ID |
81+
| `trigger_id` | 对应触发记录 ID |
82+
| `channel` | 通知渠道 |
83+
| `attempt` | 第几次尝试 |
84+
| `success` | 是否成功 |
85+
| `error_code` | 结构化错误码 |
86+
| `retryable` | 是否建议重试 |
87+
| `latency_ms` | 耗时 |
88+
| `diagnostics` | 脱敏后的发送诊断,不得包含 token、完整 webhook URL、邮箱密码或 bot secret |
89+
| `created_at` | 尝试时间 |
90+
91+
### `alert_cooldown`
92+
93+
规则或目标维度的冷却状态。
94+
95+
| 字段 | 说明 |
96+
| --- | --- |
97+
| `rule_id` | 对应规则 ID |
98+
| `target` | 冷却目标 |
99+
| `severity` | 可选等级维度 |
100+
| `last_triggered_at` | 最近触发时间 |
101+
| `cooldown_until` | 冷却截止时间 |
102+
| `reason` | 冷却原因 |
103+
| `state` | 当前状态,例如 active、expired |
104+
| `updated_at` | 更新时间 |
105+
106+
## 存储方案评估
107+
108+
当前仓库已有 SQLite 存储层和 repository/service 分层:
109+
110+
- `src/storage.py` 管理 SQLite 连接、SQLAlchemy ORM 模型和 `DatabaseManager`
111+
- `src/repositories/` 放置数据访问层,例如 `PortfolioRepository`
112+
- `src/services/` 放置业务服务层,例如 `PortfolioService``PortfolioRiskService`
113+
- 默认数据库路径跟随现有配置,通常落在 `data/stock_analysis.db`
114+
115+
P1/P2 实现告警持久化时,推荐优先复用以上模式:在 storage 层定义 alert ORM 模型,在 repository 层封装 CRUD 和查询,在 service 层处理规则校验、评估状态、通知结果和冷却语义。P0 不新建表,不改变现有数据库。
116+
117+
如果后续 PR 需要 schema 变更,必须同时给出:
118+
119+
- 幂等初始化:重复启动或重复执行初始化时不得破坏已有数据。
120+
- 向后兼容:未配置告警中心时不影响每日分析、问股、通知、大盘复盘和持仓功能。
121+
- 回滚说明:最小回滚方式至少包括 revert PR;若创建了新表或索引,需要说明是否保留数据、如何手动清理。
122+
- 数据迁移边界:不得自动迁移、删除或覆盖 `AGENT_EVENT_ALERT_RULES_JSON`,除非用户显式执行导入动作。
123+
124+
## Phase 边界
125+
126+
- P0:本文档、契约、存储评估和兼容测试。
127+
- P1:Alert API MVP,首版只覆盖现有三类 runtime 规则。
128+
- P2:告警评估 worker 与 runtime 统一,让持久化 active rules 与 legacy JSON 共存。
129+
- P3:Web 告警中心 MVP。
130+
- P4:触发历史、通知结果与冷却状态。
131+
- P5:技术指标规则。
132+
- P6:持仓与自选股联动。
133+
- P7:大盘红绿灯与市场联动。
134+
- P8:文档、迁移与收口。
135+
136+
## P0 不做
137+
138+
- 不新增 `src/schemas/alerts.py` 或 Alert API。
139+
- 不新增 Web 告警中心页面、路由或侧边栏入口。
140+
- 不新增数据库表、repository 或 migration。
141+
- 不实现触发历史、通知结果或冷却状态写入。
142+
- 不自动迁移、删除或覆盖 `AGENT_EVENT_ALERT_RULES_JSON`
143+
- 不实现 MACD、KDJ、CCI、RSI、持仓风险或 Market Light 告警规则。
144+
- 不重写 `NotificationService` 或通知路由框架。
145+
146+
## 回滚
147+
148+
P0 是文档和测试收口。若需要回滚,revert 对应 PR 即可;没有数据库、配置或用户数据迁移需要额外处理。

tests/test_alerts_docs.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# -*- coding: utf-8 -*-
2+
"""Contract checks for the alert-center P0 documentation."""
3+
4+
from pathlib import Path
5+
6+
7+
PROJECT_ROOT = Path(__file__).resolve().parents[1]
8+
DOC_PATH = PROJECT_ROOT / "docs" / "alerts.md"
9+
10+
11+
def _read_doc() -> str:
12+
return DOC_PATH.read_text(encoding="utf-8")
13+
14+
15+
def test_alerts_doc_exists_and_links_p0_scope() -> None:
16+
doc = _read_doc()
17+
18+
assert "Issue #1202 P0" in doc
19+
assert "AGENT_EVENT_ALERT_RULES_JSON" in doc
20+
assert "EventMonitor" in doc
21+
assert "P0 不做" in doc
22+
23+
24+
def test_alerts_doc_covers_legacy_runtime_rules() -> None:
25+
doc = _read_doc()
26+
27+
for token in ("price_cross", "price_change_percent", "volume_spike"):
28+
assert token in doc
29+
for token in ("sentiment_shift", "risk_flag", "custom"):
30+
assert token in doc
31+
32+
33+
def test_alerts_doc_defines_required_contract_entities() -> None:
34+
doc = _read_doc()
35+
36+
required_sections = (
37+
"### `alert_rule`",
38+
"### `alert_trigger`",
39+
"### `alert_notification`",
40+
"### `alert_cooldown`",
41+
)
42+
for section in required_sections:
43+
assert section in doc
44+
45+
required_fields = (
46+
"target_scope",
47+
"parameters",
48+
"cooldown_policy",
49+
"notification_policy",
50+
"observed_value",
51+
"data_timestamp",
52+
"trigger_id",
53+
"latency_ms",
54+
"cooldown_until",
55+
)
56+
for field_name in required_fields:
57+
assert field_name in doc
58+
59+
60+
def test_alerts_doc_covers_storage_evaluation_and_rollback() -> None:
61+
doc = _read_doc()
62+
63+
assert (PROJECT_ROOT / "src" / "storage.py").is_file()
64+
65+
for token in (
66+
"## 存储方案评估",
67+
"src/storage.py",
68+
"src/repositories/",
69+
"src/services/",
70+
"data/stock_analysis.db",
71+
"幂等初始化",
72+
"回滚说明",
73+
):
74+
assert token in doc
75+
76+
77+
def test_alerts_doc_keeps_p0_non_goals_explicit() -> None:
78+
doc = _read_doc()
79+
80+
for token in (
81+
"不新增 `src/schemas/alerts.py`",
82+
"不新增 Web 告警中心页面",
83+
"不新增数据库表",
84+
"不实现触发历史",
85+
"不自动迁移、删除或覆盖 `AGENT_EVENT_ALERT_RULES_JSON`",
86+
"不重写 `NotificationService`",
87+
):
88+
assert token in doc

tests/test_main_schedule_mode.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,9 @@ def _make_config(self, **overrides):
8989
"schedule_time": "18:00",
9090
"schedule_run_immediately": True,
9191
"run_immediately": True,
92+
"agent_event_monitor_enabled": False,
93+
"agent_event_alert_rules_json": "",
94+
"agent_event_monitor_interval_minutes": 5,
9295
}
9396
defaults.update(overrides)
9497
return _DummyConfig(**defaults)
@@ -173,6 +176,93 @@ def fake_run_with_schedule(
173176
)
174177
run_full_analysis.assert_called_once_with(runtime_config, args, None)
175178

179+
def test_schedule_mode_registers_event_monitor_background_task(self) -> None:
180+
args = self._make_args(schedule=True)
181+
config = self._make_config(
182+
schedule_enabled=False,
183+
agent_event_monitor_enabled=True,
184+
agent_event_monitor_interval_minutes=7,
185+
)
186+
monitor = object()
187+
scheduled_call = {}
188+
189+
def fake_run_with_schedule(
190+
task,
191+
schedule_time,
192+
run_immediately,
193+
background_tasks=None,
194+
schedule_time_provider=None,
195+
):
196+
scheduled_call["schedule_time"] = schedule_time
197+
scheduled_call["run_immediately"] = run_immediately
198+
scheduled_call["background_tasks"] = background_tasks or []
199+
scheduled_call["resolved_schedule_time"] = (
200+
schedule_time_provider() if schedule_time_provider is not None else None
201+
)
202+
203+
with patch("main.parse_arguments", return_value=args), \
204+
patch("main.get_config", return_value=config), \
205+
patch("main._reload_runtime_config", return_value=config), \
206+
patch("main._build_schedule_time_provider", return_value=lambda: "18:00"), \
207+
patch("main.setup_logging"), \
208+
patch("main.run_full_analysis") as run_full_analysis, \
209+
patch("src.agent.events.build_event_monitor_from_config", return_value=monitor) as build_monitor, \
210+
patch("src.agent.events.run_event_monitor_once", return_value=["triggered"]) as run_monitor, \
211+
patch("src.scheduler.run_with_schedule", side_effect=fake_run_with_schedule):
212+
exit_code = main.main()
213+
214+
self.assertEqual(exit_code, 0)
215+
build_monitor.assert_called_once_with(config)
216+
run_full_analysis.assert_not_called()
217+
self.assertEqual(scheduled_call["schedule_time"], "18:00")
218+
self.assertEqual(scheduled_call["run_immediately"], True)
219+
self.assertEqual(scheduled_call["resolved_schedule_time"], "18:00")
220+
self.assertEqual(len(scheduled_call["background_tasks"]), 1)
221+
background_task = scheduled_call["background_tasks"][0]
222+
self.assertEqual(background_task["name"], "agent_event_monitor")
223+
self.assertEqual(background_task["interval_seconds"], 7 * 60)
224+
self.assertEqual(background_task["run_immediately"], True)
225+
226+
background_task["task"]()
227+
228+
run_monitor.assert_called_once_with(monitor)
229+
230+
def test_schedule_mode_skips_event_monitor_background_task_without_valid_rules(self) -> None:
231+
args = self._make_args(schedule=True)
232+
config = self._make_config(
233+
schedule_enabled=False,
234+
agent_event_monitor_enabled=True,
235+
)
236+
scheduled_call = {}
237+
238+
def fake_run_with_schedule(
239+
task,
240+
schedule_time,
241+
run_immediately,
242+
background_tasks=None,
243+
schedule_time_provider=None,
244+
):
245+
scheduled_call["background_tasks"] = background_tasks or []
246+
247+
with patch("main.parse_arguments", return_value=args), \
248+
patch("main.get_config", return_value=config), \
249+
patch("main._reload_runtime_config", return_value=config), \
250+
patch("main._build_schedule_time_provider", return_value=lambda: "18:00"), \
251+
patch("main.setup_logging"), \
252+
patch("main.run_full_analysis") as run_full_analysis, \
253+
patch("main.logger.info") as info_log, \
254+
patch("src.agent.events.build_event_monitor_from_config", return_value=None) as build_monitor, \
255+
patch("src.agent.events.run_event_monitor_once") as run_monitor, \
256+
patch("src.scheduler.run_with_schedule", side_effect=fake_run_with_schedule):
257+
exit_code = main.main()
258+
259+
self.assertEqual(exit_code, 0)
260+
build_monitor.assert_called_once_with(config)
261+
run_monitor.assert_not_called()
262+
run_full_analysis.assert_not_called()
263+
self.assertEqual(scheduled_call["background_tasks"], [])
264+
info_log.assert_any_call("EventMonitor 已启用,但未加载到有效规则,跳过后台提醒任务")
265+
176266
def test_check_notify_returns_before_other_modes(self) -> None:
177267
args = self._make_args(check_notify=True, serve=True, schedule=True, market_review=True)
178268
config = self._make_config(webui_enabled=False)

0 commit comments

Comments
 (0)