Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

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

## [3.16.0] - 2026-05-10

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

Expand Down
1 change: 1 addition & 0 deletions docs/INDEX_EN.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ This is the entry point for project documentation. The README covers the project
| --- | --- |
| [Bot Commands (EN)](bot-command_EN.md) | Bot commands, webhooks, platform integration, and callback behavior |
| [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 |
| [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 |
| [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 |
| [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 |

Expand Down
148 changes: 148 additions & 0 deletions docs/alerts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
# 实时告警中心

本文档记录 Issue #1202 P0 的告警中心基线、数据契约、存储评估和兼容边界。P0 只定义后续实现可以复用的契约,不新增 API、Web 页面、数据库表、触发历史写入、冷却执行或规则迁移。

## 当前基线

当前运行时告警由 `src/agent/events.py` 中的 `EventMonitor` 提供,并通过 schedule 模式后台轮询执行。

- 配置入口:`AGENT_EVENT_MONITOR_ENABLED`、`AGENT_EVENT_MONITOR_INTERVAL_MINUTES`、`AGENT_EVENT_ALERT_RULES_JSON`。
- 运行入口:`main.py` 在 schedule 模式中调用 `build_event_monitor_from_config()`,并注册 `agent_event_monitor` 后台任务。
- 通知投递:触发后复用 `NotificationService.send(..., route_type="alert")`,继续遵守通知网关的 alert 路由配置。
- Web/System 配置校验:`src/services/system_config_service.py` 会对 `AGENT_EVENT_ALERT_RULES_JSON` 做 JSON 与规则语义校验。

当前 runtime 支持三类规则:

| `alert_type` | 方向字段 | 阈值字段 | 当前语义 |
| --- | --- | --- | --- |
| `price_cross` | `direction`: `above` / `below` | `price` | 实时价格上破或下破固定价格 |
| `price_change_percent` | `direction`: `up` / `down` | `change_pct` | 实时涨跌幅达到指定百分比 |
| `volume_spike` | - | `multiplier` | 最新成交量超过近 20 日均量的指定倍数 |

`sentiment_shift`、`risk_flag`、`custom` 等类型只作为未来扩展占位;当前运行时不接受这些类型作为可执行规则。

## Legacy 配置兼容

P0 保留 `AGENT_EVENT_ALERT_RULES_JSON` 作为唯一运行时规则来源,不自动迁移、删除、覆盖或改写用户已有 `.env` / Web 配置。

- 空字符串或空数组表示未配置规则;启用 EventMonitor 但没有有效规则时,schedule 模式不会注册后台告警任务。
- Web/System 配置保存时执行严格校验,JSON 无效、字段缺失、方向非法、阈值非法或 unsupported rule type 都应返回配置错误。
- 运行时加载时允许跳过单条无效规则,剩余有效规则继续工作,避免单条配置破坏整个 schedule 进程。
- 当前规则触发后会在进程内标记为 `triggered`,这不是告警中心冷却模型,也不提供跨进程或重启后的触发历史。

## 数据契约

以下契约用于后续 P1+ API、worker、Web 和存储实现对齐。P0 只定义字段和语义边界,不代表当前已经存在这些持久化实体。

### `alert_rule`

可管理的告警规则。

| 字段 | 说明 |
| --- | --- |
| `id` | 规则 ID;legacy JSON 规则在 P0 中没有持久化 ID |
| `name` | 用户可读名称;没有提供时可由规则类型和目标生成 |
| `target_scope` | 目标范围,例如 single symbol、watchlist、portfolio、market |
| `target` | 目标标的或目标引用,例如股票代码、watchlist ID、portfolio ID |
| `alert_type` | 规则类型;P1 初始只允许 `price_cross`、`price_change_percent`、`volume_spike` |
| `parameters` | 规则参数,例如 `direction`、`price`、`change_pct`、`multiplier` |
| `severity` | 告警等级,例如 info、warning、critical |
| `enabled` | 是否启用 |
| `cooldown_policy` | 冷却策略;P0 只定义字段,P4 才实现执行语义 |
| `notification_policy` | 通知策略;默认复用 `NotificationService` 的 alert 路由 |
| `source` | 创建来源,例如 legacy_env、web、api、import |
| `created_at` / `updated_at` | 创建和更新时间 |

### `alert_trigger`

一次真实或可记录的规则触发。

| 字段 | 说明 |
| --- | --- |
| `id` | 触发记录 ID |
| `rule_id` | 对应规则 ID;legacy env 规则可记录临时引用 |
| `target` | 实际触发目标 |
| `observed_value` | 观察值,例如现价、涨跌幅、成交量倍数 |
| `threshold` | 触发阈值 |
| `reason` | 可读触发原因 |
| `data_source` | 数据源或 provider |
| `data_timestamp` | 数据时间;缺失时不得伪造为当前时间 |
| `triggered_at` | 触发时间 |
| `status` | 触发状态,例如 triggered、skipped、degraded、failed |
| `diagnostics` | 脱敏后的诊断信息 |

### `alert_notification`

一次触发对应的通知尝试。

| 字段 | 说明 |
| --- | --- |
| `id` | 通知尝试 ID |
| `trigger_id` | 对应触发记录 ID |
| `channel` | 通知渠道 |
| `attempt` | 第几次尝试 |
| `success` | 是否成功 |
| `error_code` | 结构化错误码 |
| `retryable` | 是否建议重试 |
| `latency_ms` | 耗时 |
| `diagnostics` | 脱敏后的发送诊断,不得包含 token、完整 webhook URL、邮箱密码或 bot secret |
| `created_at` | 尝试时间 |

### `alert_cooldown`

规则或目标维度的冷却状态。

| 字段 | 说明 |
| --- | --- |
| `rule_id` | 对应规则 ID |
| `target` | 冷却目标 |
| `severity` | 可选等级维度 |
| `last_triggered_at` | 最近触发时间 |
| `cooldown_until` | 冷却截止时间 |
| `reason` | 冷却原因 |
| `state` | 当前状态,例如 active、expired |
| `updated_at` | 更新时间 |

## 存储方案评估

当前仓库已有 SQLite 存储层和 repository/service 分层:

- `src/storage.py` 管理 SQLite 连接、SQLAlchemy ORM 模型和 `DatabaseManager`。
- `src/repositories/` 放置数据访问层,例如 `PortfolioRepository`。
- `src/services/` 放置业务服务层,例如 `PortfolioService`、`PortfolioRiskService`。
- 默认数据库路径跟随现有配置,通常落在 `data/stock_analysis.db`。

P1/P2 实现告警持久化时,推荐优先复用以上模式:在 storage 层定义 alert ORM 模型,在 repository 层封装 CRUD 和查询,在 service 层处理规则校验、评估状态、通知结果和冷却语义。P0 不新建表,不改变现有数据库。

如果后续 PR 需要 schema 变更,必须同时给出:

- 幂等初始化:重复启动或重复执行初始化时不得破坏已有数据。
- 向后兼容:未配置告警中心时不影响每日分析、问股、通知、大盘复盘和持仓功能。
- 回滚说明:最小回滚方式至少包括 revert PR;若创建了新表或索引,需要说明是否保留数据、如何手动清理。
- 数据迁移边界:不得自动迁移、删除或覆盖 `AGENT_EVENT_ALERT_RULES_JSON`,除非用户显式执行导入动作。

## Phase 边界

- P0:本文档、契约、存储评估和兼容测试。
- P1:Alert API MVP,首版只覆盖现有三类 runtime 规则。
- P2:告警评估 worker 与 runtime 统一,让持久化 active rules 与 legacy JSON 共存。
- P3:Web 告警中心 MVP。
- P4:触发历史、通知结果与冷却状态。
- P5:技术指标规则。
- P6:持仓与自选股联动。
- P7:大盘红绿灯与市场联动。
- P8:文档、迁移与收口。

## P0 不做

- 不新增 `src/schemas/alerts.py` 或 Alert API。
- 不新增 Web 告警中心页面、路由或侧边栏入口。
- 不新增数据库表、repository 或 migration。
- 不实现触发历史、通知结果或冷却状态写入。
- 不自动迁移、删除或覆盖 `AGENT_EVENT_ALERT_RULES_JSON`。
- 不实现 MACD、KDJ、CCI、RSI、持仓风险或 Market Light 告警规则。
- 不重写 `NotificationService` 或通知路由框架。

## 回滚

P0 是文档和测试收口。若需要回滚,revert 对应 PR 即可;没有数据库、配置或用户数据迁移需要额外处理。
88 changes: 88 additions & 0 deletions tests/test_alerts_docs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# -*- coding: utf-8 -*-
"""Contract checks for the alert-center P0 documentation."""

from pathlib import Path


PROJECT_ROOT = Path(__file__).resolve().parents[1]
DOC_PATH = PROJECT_ROOT / "docs" / "alerts.md"


def _read_doc() -> str:
return DOC_PATH.read_text(encoding="utf-8")


def test_alerts_doc_exists_and_links_p0_scope() -> None:
doc = _read_doc()

assert "Issue #1202 P0" in doc
assert "AGENT_EVENT_ALERT_RULES_JSON" in doc
assert "EventMonitor" in doc
assert "P0 不做" in doc


def test_alerts_doc_covers_legacy_runtime_rules() -> None:
doc = _read_doc()

for token in ("price_cross", "price_change_percent", "volume_spike"):
assert token in doc
for token in ("sentiment_shift", "risk_flag", "custom"):
assert token in doc


def test_alerts_doc_defines_required_contract_entities() -> None:
doc = _read_doc()

required_sections = (
"### `alert_rule`",
"### `alert_trigger`",
"### `alert_notification`",
"### `alert_cooldown`",
)
for section in required_sections:
assert section in doc

required_fields = (
"target_scope",
"parameters",
"cooldown_policy",
"notification_policy",
"observed_value",
"data_timestamp",
"trigger_id",
"latency_ms",
"cooldown_until",
)
for field_name in required_fields:
assert field_name in doc


def test_alerts_doc_covers_storage_evaluation_and_rollback() -> None:
doc = _read_doc()

assert (PROJECT_ROOT / "src" / "storage.py").is_file()

for token in (
"## 存储方案评估",
"src/storage.py",
"src/repositories/",
"src/services/",
"data/stock_analysis.db",
"幂等初始化",
"回滚说明",
):
assert token in doc


def test_alerts_doc_keeps_p0_non_goals_explicit() -> None:
doc = _read_doc()

for token in (
"不新增 `src/schemas/alerts.py`",
"不新增 Web 告警中心页面",
"不新增数据库表",
"不实现触发历史",
"不自动迁移、删除或覆盖 `AGENT_EVENT_ALERT_RULES_JSON`",
"不重写 `NotificationService`",
):
assert token in doc
90 changes: 90 additions & 0 deletions tests/test_main_schedule_mode.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,9 @@ def _make_config(self, **overrides):
"schedule_time": "18:00",
"schedule_run_immediately": True,
"run_immediately": True,
"agent_event_monitor_enabled": False,
"agent_event_alert_rules_json": "",
"agent_event_monitor_interval_minutes": 5,
}
defaults.update(overrides)
return _DummyConfig(**defaults)
Expand Down Expand Up @@ -173,6 +176,93 @@ def fake_run_with_schedule(
)
run_full_analysis.assert_called_once_with(runtime_config, args, None)

def test_schedule_mode_registers_event_monitor_background_task(self) -> None:
args = self._make_args(schedule=True)
config = self._make_config(
schedule_enabled=False,
agent_event_monitor_enabled=True,
agent_event_monitor_interval_minutes=7,
)
monitor = object()
scheduled_call = {}

def fake_run_with_schedule(
task,
schedule_time,
run_immediately,
background_tasks=None,
schedule_time_provider=None,
):
scheduled_call["schedule_time"] = schedule_time
scheduled_call["run_immediately"] = run_immediately
scheduled_call["background_tasks"] = background_tasks or []
scheduled_call["resolved_schedule_time"] = (
schedule_time_provider() if schedule_time_provider is not None else None
)

with patch("main.parse_arguments", return_value=args), \
patch("main.get_config", return_value=config), \
patch("main._reload_runtime_config", return_value=config), \
patch("main._build_schedule_time_provider", return_value=lambda: "18:00"), \
patch("main.setup_logging"), \
patch("main.run_full_analysis") as run_full_analysis, \
patch("src.agent.events.build_event_monitor_from_config", return_value=monitor) as build_monitor, \
patch("src.agent.events.run_event_monitor_once", return_value=["triggered"]) as run_monitor, \
patch("src.scheduler.run_with_schedule", side_effect=fake_run_with_schedule):
exit_code = main.main()

self.assertEqual(exit_code, 0)
build_monitor.assert_called_once_with(config)
run_full_analysis.assert_not_called()
self.assertEqual(scheduled_call["schedule_time"], "18:00")
self.assertEqual(scheduled_call["run_immediately"], True)
self.assertEqual(scheduled_call["resolved_schedule_time"], "18:00")
self.assertEqual(len(scheduled_call["background_tasks"]), 1)
background_task = scheduled_call["background_tasks"][0]
self.assertEqual(background_task["name"], "agent_event_monitor")
self.assertEqual(background_task["interval_seconds"], 7 * 60)
self.assertEqual(background_task["run_immediately"], True)

background_task["task"]()

run_monitor.assert_called_once_with(monitor)

def test_schedule_mode_skips_event_monitor_background_task_without_valid_rules(self) -> None:
args = self._make_args(schedule=True)
config = self._make_config(
schedule_enabled=False,
agent_event_monitor_enabled=True,
)
scheduled_call = {}

def fake_run_with_schedule(
task,
schedule_time,
run_immediately,
background_tasks=None,
schedule_time_provider=None,
):
scheduled_call["background_tasks"] = background_tasks or []

with patch("main.parse_arguments", return_value=args), \
patch("main.get_config", return_value=config), \
patch("main._reload_runtime_config", return_value=config), \
patch("main._build_schedule_time_provider", return_value=lambda: "18:00"), \
patch("main.setup_logging"), \
patch("main.run_full_analysis") as run_full_analysis, \
patch("main.logger.info") as info_log, \
patch("src.agent.events.build_event_monitor_from_config", return_value=None) as build_monitor, \
patch("src.agent.events.run_event_monitor_once") as run_monitor, \
patch("src.scheduler.run_with_schedule", side_effect=fake_run_with_schedule):
exit_code = main.main()

self.assertEqual(exit_code, 0)
build_monitor.assert_called_once_with(config)
run_monitor.assert_not_called()
run_full_analysis.assert_not_called()
self.assertEqual(scheduled_call["background_tasks"], [])
info_log.assert_any_call("EventMonitor 已启用,但未加载到有效规则,跳过后台提醒任务")

def test_check_notify_returns_before_other_modes(self) -> None:
args = self._make_args(check_notify=True, serve=True, schedule=True, market_review=True)
config = self._make_config(webui_enabled=False)
Expand Down
Loading
Loading