Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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 @@ -41,6 +41,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 边界。
- [修复] Web 设置页为通知测试与 Agent/通知配置区域增加局部运行时错误兜底,异常时提示提供 Windows 桌面端 `desktop.log`,避免整页黑屏。
- [修复] 资金流数据不可用时将直接买入结论降级为观察,避免缺失数据被误读为高置信买入依据。
- [修复] 调高基本面聚合默认超时预算,降低 Windows/Docker 环境下整段基本面 timeout 的概率。
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