tdxalert 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tdxalert-0.1.0/LICENSE +21 -0
- tdxalert-0.1.0/PKG-INFO +121 -0
- tdxalert-0.1.0/README.md +103 -0
- tdxalert-0.1.0/pyproject.toml +27 -0
- tdxalert-0.1.0/setup.cfg +4 -0
- tdxalert-0.1.0/src/tdxalert/__init__.py +23 -0
- tdxalert-0.1.0/src/tdxalert/__main__.py +3 -0
- tdxalert-0.1.0/src/tdxalert/cli.py +53 -0
- tdxalert-0.1.0/src/tdxalert/engine.py +45 -0
- tdxalert-0.1.0/src/tdxalert/notify.py +78 -0
- tdxalert-0.1.0/src/tdxalert/rules.py +104 -0
- tdxalert-0.1.0/src/tdxalert.egg-info/PKG-INFO +121 -0
- tdxalert-0.1.0/src/tdxalert.egg-info/SOURCES.txt +15 -0
- tdxalert-0.1.0/src/tdxalert.egg-info/dependency_links.txt +1 -0
- tdxalert-0.1.0/src/tdxalert.egg-info/entry_points.txt +2 -0
- tdxalert-0.1.0/src/tdxalert.egg-info/top_level.txt +1 -0
- tdxalert-0.1.0/tests/test_alert.py +63 -0
tdxalert-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 mengyiheng
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
tdxalert-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tdxalert
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: 盘中预警推送引擎:可插拔数据源 + 基础规则集 + 微信推送,零依赖
|
|
5
|
+
Author: mengyiheng
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: stock,alert,quant,wechat,monitor
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Intended Audience :: Financial and Insurance Industry
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Office/Business :: Financial :: Investment
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
<p align="center"><img src="assets/cover.png" alt="tdxalert" width="720"/></p>
|
|
20
|
+
|
|
21
|
+
# tdxalert — 盘中预警推送引擎
|
|
22
|
+
|
|
23
|
+
把你的**全市场实时快照**接进来,按规则监控,触发即推送**微信**。
|
|
24
|
+
|
|
25
|
+
- 🔌 **数据源可插拔**:引擎只吃标准化的快照帧——你自己的采集器、pytdx、
|
|
26
|
+
[tdxreplay](https://gitee.com/mengyiheng/tdxreplay) 回放流,都能接
|
|
27
|
+
- 📏 **基础规则集**:价格上/下破、涨跌幅超阈、成交量异常放大、参考线上穿/下穿,
|
|
28
|
+
每条规则独立冷却时间
|
|
29
|
+
- 💬 **微信推送**:Server酱 / PushPlus,另附钉钉/企微 webhook 与控制台渠道
|
|
30
|
+
- 🪶 **零第三方依赖**,纯 Python 标准库
|
|
31
|
+
- 🧪 规则引擎与推送解耦,方便先接回放源**离线验证预警逻辑**再上实盘
|
|
32
|
+
|
|
33
|
+
## 安装
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install tdxalert
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## 快速开始
|
|
40
|
+
|
|
41
|
+
### 1. 写配置 `alert.json`
|
|
42
|
+
|
|
43
|
+
```json
|
|
44
|
+
{
|
|
45
|
+
"notifiers": { "serverchan": { "key": "SCTxxxxxxxx 你的SendKey" } },
|
|
46
|
+
"rules": [
|
|
47
|
+
{ "type": "price_above", "code": "600519", "value": 1500, "cooldown": 600 },
|
|
48
|
+
{ "type": "pct_below", "code": "*", "value": -3 },
|
|
49
|
+
{ "type": "vol_spike", "code": "*", "window": 20, "ratio": 3 },
|
|
50
|
+
{ "type": "change_dir", "code": "000001", "value": 10.5 }
|
|
51
|
+
]
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### 2. 把你的快照流接进来(每行一帧 JSON)
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
{"600519": {"price": 1501.2, "pct": 1.2, "vol": 3200, "name": "贵州茅台"}, "000001": {...}}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
your_snapshot_source | tdxalert run --config alert.json
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### 3. 或者只用 Python API
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
from tdxalert import AlertEngine
|
|
69
|
+
|
|
70
|
+
engine = AlertEngine.from_config(cfg)
|
|
71
|
+
for frame in my_snapshot_loop(): # 你的 REA / 采集器
|
|
72
|
+
engine.feed(frame) # 触发即推微信
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## 规则类型
|
|
76
|
+
|
|
77
|
+
| type | 含义 | 专属参数 |
|
|
78
|
+
|---|---|---|
|
|
79
|
+
| `price_above` / `price_below` | 价格上破 / 下破 | `value` 阈值 |
|
|
80
|
+
| `pct_above` / `pct_below` | 涨跌幅超阈(%) | `value` |
|
|
81
|
+
| `vol_spike` | 成交量较滚动均值放大 | `window` 窗口根数、`ratio` 倍数 |
|
|
82
|
+
| `change_dir` | 上穿 / 下穿参考线 | `value` 参考价 |
|
|
83
|
+
|
|
84
|
+
通用参数:`code`(`"*"`=全市场)、`cooldown` 冷却秒数(默认 300)。
|
|
85
|
+
|
|
86
|
+
## 快照帧字段约定
|
|
87
|
+
|
|
88
|
+
| 字段 | 必需 | 说明 |
|
|
89
|
+
|---|---|---|
|
|
90
|
+
| `price` | ✅ | 最新价 |
|
|
91
|
+
| `pct` | 建议 | 涨跌幅 %(正涨负跌) |
|
|
92
|
+
| `vol` | 建议 | 成交量(vol_spike 需要) |
|
|
93
|
+
| `name` | 可选 | 证券名称(出现在告警文案里) |
|
|
94
|
+
|
|
95
|
+
## 提示
|
|
96
|
+
|
|
97
|
+
- **先离线验证再上实盘**:把 [tdxreplay](https://gitee.com/mengyiheng/tdxreplay)
|
|
98
|
+
的回放流转成快照帧喂进来,历史数据上确认规则触发符合预期后再接实时源;
|
|
99
|
+
- Server酱 SendKey 获取:<https://sct.ftqq.com/>(微信扫码即用);
|
|
100
|
+
- 规则误报多就加大 `cooldown`;放量告警灵敏度高,`window` 建议不低于 10。
|
|
101
|
+
|
|
102
|
+
## 免责声明
|
|
103
|
+
|
|
104
|
+
本项目仅为个人研究工具,不构成任何投资建议;告警可能延迟、丢失或误报。
|
|
105
|
+
使用前请[阅读完整免责声明](DISCLAIMER.md)。
|
|
106
|
+
|
|
107
|
+
## License
|
|
108
|
+
|
|
109
|
+
MIT
|
|
110
|
+
|
|
111
|
+
## 量化相关定制需求
|
|
112
|
+
|
|
113
|
+
预警引擎恰恰是最值得按个人习惯定制的模块:
|
|
114
|
+
|
|
115
|
+
| 方向 | 示例 |
|
|
116
|
+
|---|---|
|
|
117
|
+
| 📏 规则扩展 | 接入你的策略条件、多规则组合、板块/概念联动预警 |
|
|
118
|
+
| 💬 渠道扩展 | Telegram/邮件/短信、按告警级别分流推送 |
|
|
119
|
+
| 🔌 源适配 | 把你现有的采集器/行情源接成标准快照帧 |
|
|
120
|
+
|
|
121
|
+
**联系合作**:[Gitee Issues](https://gitee.com/mengyiheng/tdxalert/issues) 或 [Gitee 私信](https://gitee.com/mengyiheng)。规则类需求请附:监控对象、触发条件、期望推送方式。
|
tdxalert-0.1.0/README.md
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
<p align="center"><img src="assets/cover.png" alt="tdxalert" width="720"/></p>
|
|
2
|
+
|
|
3
|
+
# tdxalert — 盘中预警推送引擎
|
|
4
|
+
|
|
5
|
+
把你的**全市场实时快照**接进来,按规则监控,触发即推送**微信**。
|
|
6
|
+
|
|
7
|
+
- 🔌 **数据源可插拔**:引擎只吃标准化的快照帧——你自己的采集器、pytdx、
|
|
8
|
+
[tdxreplay](https://gitee.com/mengyiheng/tdxreplay) 回放流,都能接
|
|
9
|
+
- 📏 **基础规则集**:价格上/下破、涨跌幅超阈、成交量异常放大、参考线上穿/下穿,
|
|
10
|
+
每条规则独立冷却时间
|
|
11
|
+
- 💬 **微信推送**:Server酱 / PushPlus,另附钉钉/企微 webhook 与控制台渠道
|
|
12
|
+
- 🪶 **零第三方依赖**,纯 Python 标准库
|
|
13
|
+
- 🧪 规则引擎与推送解耦,方便先接回放源**离线验证预警逻辑**再上实盘
|
|
14
|
+
|
|
15
|
+
## 安装
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pip install tdxalert
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## 快速开始
|
|
22
|
+
|
|
23
|
+
### 1. 写配置 `alert.json`
|
|
24
|
+
|
|
25
|
+
```json
|
|
26
|
+
{
|
|
27
|
+
"notifiers": { "serverchan": { "key": "SCTxxxxxxxx 你的SendKey" } },
|
|
28
|
+
"rules": [
|
|
29
|
+
{ "type": "price_above", "code": "600519", "value": 1500, "cooldown": 600 },
|
|
30
|
+
{ "type": "pct_below", "code": "*", "value": -3 },
|
|
31
|
+
{ "type": "vol_spike", "code": "*", "window": 20, "ratio": 3 },
|
|
32
|
+
{ "type": "change_dir", "code": "000001", "value": 10.5 }
|
|
33
|
+
]
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### 2. 把你的快照流接进来(每行一帧 JSON)
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
{"600519": {"price": 1501.2, "pct": 1.2, "vol": 3200, "name": "贵州茅台"}, "000001": {...}}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
your_snapshot_source | tdxalert run --config alert.json
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### 3. 或者只用 Python API
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from tdxalert import AlertEngine
|
|
51
|
+
|
|
52
|
+
engine = AlertEngine.from_config(cfg)
|
|
53
|
+
for frame in my_snapshot_loop(): # 你的 REA / 采集器
|
|
54
|
+
engine.feed(frame) # 触发即推微信
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## 规则类型
|
|
58
|
+
|
|
59
|
+
| type | 含义 | 专属参数 |
|
|
60
|
+
|---|---|---|
|
|
61
|
+
| `price_above` / `price_below` | 价格上破 / 下破 | `value` 阈值 |
|
|
62
|
+
| `pct_above` / `pct_below` | 涨跌幅超阈(%) | `value` |
|
|
63
|
+
| `vol_spike` | 成交量较滚动均值放大 | `window` 窗口根数、`ratio` 倍数 |
|
|
64
|
+
| `change_dir` | 上穿 / 下穿参考线 | `value` 参考价 |
|
|
65
|
+
|
|
66
|
+
通用参数:`code`(`"*"`=全市场)、`cooldown` 冷却秒数(默认 300)。
|
|
67
|
+
|
|
68
|
+
## 快照帧字段约定
|
|
69
|
+
|
|
70
|
+
| 字段 | 必需 | 说明 |
|
|
71
|
+
|---|---|---|
|
|
72
|
+
| `price` | ✅ | 最新价 |
|
|
73
|
+
| `pct` | 建议 | 涨跌幅 %(正涨负跌) |
|
|
74
|
+
| `vol` | 建议 | 成交量(vol_spike 需要) |
|
|
75
|
+
| `name` | 可选 | 证券名称(出现在告警文案里) |
|
|
76
|
+
|
|
77
|
+
## 提示
|
|
78
|
+
|
|
79
|
+
- **先离线验证再上实盘**:把 [tdxreplay](https://gitee.com/mengyiheng/tdxreplay)
|
|
80
|
+
的回放流转成快照帧喂进来,历史数据上确认规则触发符合预期后再接实时源;
|
|
81
|
+
- Server酱 SendKey 获取:<https://sct.ftqq.com/>(微信扫码即用);
|
|
82
|
+
- 规则误报多就加大 `cooldown`;放量告警灵敏度高,`window` 建议不低于 10。
|
|
83
|
+
|
|
84
|
+
## 免责声明
|
|
85
|
+
|
|
86
|
+
本项目仅为个人研究工具,不构成任何投资建议;告警可能延迟、丢失或误报。
|
|
87
|
+
使用前请[阅读完整免责声明](DISCLAIMER.md)。
|
|
88
|
+
|
|
89
|
+
## License
|
|
90
|
+
|
|
91
|
+
MIT
|
|
92
|
+
|
|
93
|
+
## 量化相关定制需求
|
|
94
|
+
|
|
95
|
+
预警引擎恰恰是最值得按个人习惯定制的模块:
|
|
96
|
+
|
|
97
|
+
| 方向 | 示例 |
|
|
98
|
+
|---|---|
|
|
99
|
+
| 📏 规则扩展 | 接入你的策略条件、多规则组合、板块/概念联动预警 |
|
|
100
|
+
| 💬 渠道扩展 | Telegram/邮件/短信、按告警级别分流推送 |
|
|
101
|
+
| 🔌 源适配 | 把你现有的采集器/行情源接成标准快照帧 |
|
|
102
|
+
|
|
103
|
+
**联系合作**:[Gitee Issues](https://gitee.com/mengyiheng/tdxalert/issues) 或 [Gitee 私信](https://gitee.com/mengyiheng)。规则类需求请附:监控对象、触发条件、期望推送方式。
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "tdxalert"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "盘中预警推送引擎:可插拔数据源 + 基础规则集 + 微信推送,零依赖"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "mengyiheng" }]
|
|
13
|
+
keywords = ["stock", "alert", "quant", "wechat", "monitor"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Intended Audience :: Financial and Insurance Industry",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Topic :: Office/Business :: Financial :: Investment",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.scripts]
|
|
24
|
+
tdxalert = "tdxalert.cli:main"
|
|
25
|
+
|
|
26
|
+
[tool.setuptools.packages.find]
|
|
27
|
+
where = ["src"]
|
tdxalert-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""tdxalert — 盘中预警推送引擎(数据源无关)。
|
|
2
|
+
|
|
3
|
+
数据源可插拔:任何能产出 ``{code: {"price": .., "pct": .., "vol": ..}}``
|
|
4
|
+
快照帧的代码都能接入,包括你自己的全市场采集器、pytdx、或 tdxreplay 回放流。
|
|
5
|
+
|
|
6
|
+
from tdxalert import AlertEngine
|
|
7
|
+
|
|
8
|
+
engine = AlertEngine.from_config({
|
|
9
|
+
"notifiers": {"serverchan": {"key": "SCTxxx"}},
|
|
10
|
+
"rules": [
|
|
11
|
+
{"type": "price_above", "code": "600519", "value": 1500},
|
|
12
|
+
{"type": "vol_spike", "code": "*", "window": 20, "ratio": 3},
|
|
13
|
+
],
|
|
14
|
+
})
|
|
15
|
+
for frame in your_snapshot_loop(): # 你的 REA 全市场快照
|
|
16
|
+
engine.feed(frame) # 触发即推送微信
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from .engine import AlertEngine
|
|
20
|
+
from .rules import Rule, evaluate, parse
|
|
21
|
+
|
|
22
|
+
__version__ = "0.1.0"
|
|
23
|
+
__all__ = ["AlertEngine", "Rule", "evaluate", "parse", "__version__"]
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""tdxalert.cli — 命令行:读 JSONL 快照流,跑规则,推微信。
|
|
2
|
+
|
|
3
|
+
用法:
|
|
4
|
+
tdxalert run --config alert.json --input quotes.jsonl # 快照文件/管道
|
|
5
|
+
your_snapshot_source | tdxalert run --config alert.jsonl # 接你自己的采集器
|
|
6
|
+
|
|
7
|
+
快照帧格式(每行一个 JSON)::
|
|
8
|
+
{"600519": {"price": 1501.2, "pct": 1.2, "vol": 3200, "name": "贵州茅台"}, ...}
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import json
|
|
15
|
+
import sys
|
|
16
|
+
import time
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def main(argv: list[str] | None = None) -> int:
|
|
20
|
+
p = argparse.ArgumentParser(prog="tdxalert", description="盘中预警推送引擎")
|
|
21
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
22
|
+
run = sub.add_parser("run", help="消费 JSONL 快照流并推送告警")
|
|
23
|
+
run.add_argument("--config", required=True, help="配置 JSON(规则 + 推送渠道)")
|
|
24
|
+
run.add_argument("--input", default="-", help="JSONL 快照流文件,默认 stdin")
|
|
25
|
+
run.add_argument("--interval", type=float, default=0.0,
|
|
26
|
+
help="每帧之间的模拟间隔秒数(回放测试用)")
|
|
27
|
+
a = p.parse_args(argv)
|
|
28
|
+
|
|
29
|
+
from .engine import AlertEngine
|
|
30
|
+
|
|
31
|
+
cfg = json.loads(open(a.config, encoding="utf-8-sig").read())
|
|
32
|
+
engine = AlertEngine.from_config(cfg)
|
|
33
|
+
print(f"[tdxalert] 规则 {len(engine.rules)} 条,渠道: {list(engine.notifiers)}")
|
|
34
|
+
|
|
35
|
+
stream = sys.stdin if a.input == "-" else open(a.input, encoding="utf-8")
|
|
36
|
+
n = 0
|
|
37
|
+
for line in stream:
|
|
38
|
+
line = line.strip().lstrip("\ufeff")
|
|
39
|
+
if not line:
|
|
40
|
+
continue
|
|
41
|
+
frame = json.loads(line)
|
|
42
|
+
alerts = engine.feed(frame)
|
|
43
|
+
for text in alerts:
|
|
44
|
+
print(f"[触发] {text}")
|
|
45
|
+
n += 1
|
|
46
|
+
if a.interval:
|
|
47
|
+
time.sleep(a.interval)
|
|
48
|
+
print(f"[tdxalert] 处理 {n} 帧,触发 {len(engine.sent)} 条告警")
|
|
49
|
+
return 0
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
if __name__ == "__main__":
|
|
53
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""tdxalert.engine — 预警引擎:吃快照,吐告警。
|
|
2
|
+
|
|
3
|
+
数据源无关:任何能产出 ``{code: {"price": float, "pct": float, "vol": float,
|
|
4
|
+
"name": str}}`` 快照的代码都能接入::
|
|
5
|
+
|
|
6
|
+
engine = AlertEngine.from_config(cfg)
|
|
7
|
+
for frame in your_snapshot_loop(): # 你的 REA 快照
|
|
8
|
+
for msg in engine.feed(frame):
|
|
9
|
+
... # 已推送到配置的渠道
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Any, Dict, List
|
|
15
|
+
|
|
16
|
+
from .notify import build_notifiers
|
|
17
|
+
from .rules import Rule, evaluate, parse
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AlertEngine:
|
|
21
|
+
def __init__(self, rules: List[Rule], notifiers: Dict[str, Any] | None = None):
|
|
22
|
+
self.rules = rules
|
|
23
|
+
self.notifiers = build_notifiers(notifiers or {})
|
|
24
|
+
self.sent: List[str] = []
|
|
25
|
+
|
|
26
|
+
@classmethod
|
|
27
|
+
def from_config(cls, cfg: Dict[str, Any]) -> "AlertEngine":
|
|
28
|
+
rules = [parse(c) for c in cfg.get("rules", [])]
|
|
29
|
+
return cls(rules, cfg.get("notifiers", {}))
|
|
30
|
+
|
|
31
|
+
def feed(self, quotes: Dict[str, Dict[str, Any]]) -> List[str]:
|
|
32
|
+
"""处理一帧全市场快照,返回触发的告警文本(同时推送渠道)。"""
|
|
33
|
+
alerts: List[str] = []
|
|
34
|
+
for rule in self.rules:
|
|
35
|
+
for code, q in quotes.items():
|
|
36
|
+
msg = evaluate(rule, code, q)
|
|
37
|
+
if msg:
|
|
38
|
+
name = q.get("name", "")
|
|
39
|
+
alerts.append(f"{name} {msg}" if name else msg)
|
|
40
|
+
if alerts:
|
|
41
|
+
for text in alerts:
|
|
42
|
+
self.sent.append(text)
|
|
43
|
+
for n in self.notifiers.values():
|
|
44
|
+
n.send(text)
|
|
45
|
+
return alerts
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""tdxalert.notify — 推送渠道(可插拔,纯标准库实现)。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import urllib.parse
|
|
7
|
+
import urllib.request
|
|
8
|
+
from typing import Any, Dict, Protocol
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Notifier(Protocol):
|
|
12
|
+
def send(self, text: str) -> None: ...
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ConsoleNotifier:
|
|
16
|
+
"""本地控制台输出(默认兜底渠道)。"""
|
|
17
|
+
|
|
18
|
+
def send(self, text: str) -> None:
|
|
19
|
+
print(f"[tdxalert] {text}")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ServerChanNotifier:
|
|
23
|
+
"""Server酱·Turbo 版:https://sct.ftqq.com/ 微信服务号消息。"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, key: str):
|
|
26
|
+
self.key = key
|
|
27
|
+
|
|
28
|
+
def send(self, text: str) -> None:
|
|
29
|
+
data = urllib.parse.urlencode({"title": text[:32], "desp": text}).encode()
|
|
30
|
+
urllib.request.urlopen(f"https://sctapi.ftqq.com/{self.key}.send",
|
|
31
|
+
data=data, timeout=10)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class PushPlusNotifier:
|
|
35
|
+
"""PushPlus:https://www.pushplus.plus/ 微信公众号消息。"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, token: str):
|
|
38
|
+
self.token = token
|
|
39
|
+
|
|
40
|
+
def send(self, text: str) -> None:
|
|
41
|
+
body = json.dumps({"token": self.token, "title": text[:64],
|
|
42
|
+
"content": text, "template": "txt"}).encode()
|
|
43
|
+
req = urllib.request.Request("https://www.pushplus.plus/send", data=body,
|
|
44
|
+
headers={"Content-Type": "application/json"})
|
|
45
|
+
urllib.request.urlopen(req, timeout=10)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class WebhookNotifier:
|
|
49
|
+
"""通用 webhook(钉钉/企微机器人等 POST JSON 接口)。"""
|
|
50
|
+
|
|
51
|
+
def __init__(self, url: str):
|
|
52
|
+
self.url = url
|
|
53
|
+
|
|
54
|
+
def send(self, text: str) -> None:
|
|
55
|
+
body = json.dumps({"msgtype": "text", "text": {"content": text}}).encode()
|
|
56
|
+
req = urllib.request.Request(self.url, data=body,
|
|
57
|
+
headers={"Content-Type": "application/json"})
|
|
58
|
+
urllib.request.urlopen(req, timeout=10)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
_BUILDERS = {
|
|
62
|
+
"console": lambda cfg: ConsoleNotifier(),
|
|
63
|
+
"serverchan": lambda cfg: ServerChanNotifier(cfg["key"]),
|
|
64
|
+
"pushplus": lambda cfg: PushPlusNotifier(cfg["token"]),
|
|
65
|
+
"webhook": lambda cfg: WebhookNotifier(cfg["url"]),
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def build_notifiers(cfg: Dict[str, Any]) -> Dict[str, Notifier]:
|
|
70
|
+
out: Dict[str, Notifier] = {}
|
|
71
|
+
if not cfg:
|
|
72
|
+
out["console"] = ConsoleNotifier()
|
|
73
|
+
return out
|
|
74
|
+
for name, c in cfg.items():
|
|
75
|
+
if name not in _BUILDERS:
|
|
76
|
+
raise ValueError(f"未知推送渠道: {name}(支持 {list(_BUILDERS)})")
|
|
77
|
+
out[name] = _BUILDERS[name](c if isinstance(c, dict) else {"key": c})
|
|
78
|
+
return out
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""tdxalert.rules — 基础预警规则集。
|
|
2
|
+
|
|
3
|
+
规则在每帧快照上求值,返回告警消息(str)或 None。
|
|
4
|
+
|
|
5
|
+
所有规则通用字段:
|
|
6
|
+
code 证券代码,"*" 表示全市场
|
|
7
|
+
cooldown 同一规则对同一代码的告警冷却秒数(默认 300)
|
|
8
|
+
|
|
9
|
+
内置类型:
|
|
10
|
+
price_above / price_below 价格上破 / 下破阈值
|
|
11
|
+
pct_above / pct_below 涨跌幅超阈(%,正涨负跌)
|
|
12
|
+
vol_spike 成交量较滚动均值放大 ratio 倍
|
|
13
|
+
change_dir 方向反转(price 上穿/下穿 ref_price)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from datetime import datetime
|
|
20
|
+
from typing import Any, Dict, List, Optional
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class Rule:
|
|
25
|
+
type: str
|
|
26
|
+
code: str = "*"
|
|
27
|
+
value: float = 0.0
|
|
28
|
+
window: int = 20 # vol_spike 滚动窗口
|
|
29
|
+
ratio: float = 3.0 # vol_spike 放大倍数
|
|
30
|
+
cooldown: int = 300
|
|
31
|
+
name: str = ""
|
|
32
|
+
|
|
33
|
+
# 运行时状态
|
|
34
|
+
last_fired: Dict[str, str] = field(default_factory=dict) # code -> ISO 时间
|
|
35
|
+
vol_hist: Dict[str, List[float]] = field(default_factory=dict)
|
|
36
|
+
|
|
37
|
+
def matches(self, code: str) -> bool:
|
|
38
|
+
return self.code in ("*", code)
|
|
39
|
+
|
|
40
|
+
def _cooled(self, code: str) -> bool:
|
|
41
|
+
t = self.last_fired.get(code)
|
|
42
|
+
if not t:
|
|
43
|
+
return True
|
|
44
|
+
return (datetime.now() - datetime.fromisoformat(t)).total_seconds() >= self.cooldown
|
|
45
|
+
|
|
46
|
+
def _fire(self, code: str) -> None:
|
|
47
|
+
self.last_fired[code] = datetime.now().isoformat(timespec="seconds")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def parse(cfg: Dict[str, Any]) -> Rule:
|
|
51
|
+
known = {"type", "code", "value", "window", "ratio", "cooldown", "name"}
|
|
52
|
+
extra = set(cfg) - known
|
|
53
|
+
if extra:
|
|
54
|
+
raise ValueError(f"规则含未知字段: {extra}(规则: {cfg})")
|
|
55
|
+
r = Rule(type=cfg["type"], **{k: v for k, v in cfg.items() if k not in ("type", "name")})
|
|
56
|
+
r.name = cfg.get("name") or cfg["type"]
|
|
57
|
+
return r
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def evaluate(rule: Rule, code: str, q: Dict[str, Any]) -> Optional[str]:
|
|
61
|
+
"""对单只股票快照求值。q 至少含 price;pct/vol/amount 可选。"""
|
|
62
|
+
if not rule.matches(code) or not rule._cooled(code):
|
|
63
|
+
return None
|
|
64
|
+
price = q.get("price")
|
|
65
|
+
if price is None:
|
|
66
|
+
return None
|
|
67
|
+
msg: Optional[str] = None
|
|
68
|
+
|
|
69
|
+
if rule.type == "price_above" and price > rule.value:
|
|
70
|
+
msg = f"价格 {price} 上破 {rule.value}"
|
|
71
|
+
elif rule.type == "price_below" and price < rule.value:
|
|
72
|
+
msg = f"价格 {price} 下破 {rule.value}"
|
|
73
|
+
elif rule.type == "pct_above" and (q.get("pct") or 0) > rule.value:
|
|
74
|
+
msg = f"涨幅 {q['pct']}% > {rule.value}%"
|
|
75
|
+
elif rule.type == "pct_below" and (q.get("pct") or 0) < rule.value:
|
|
76
|
+
msg = f"跌幅 {q['pct']}% < {rule.value}%"
|
|
77
|
+
elif rule.type == "change_dir":
|
|
78
|
+
ref = rule.value
|
|
79
|
+
hist = rule.vol_hist.setdefault(code, [])
|
|
80
|
+
prev = hist[-1] if hist else None
|
|
81
|
+
hist.append(price)
|
|
82
|
+
if prev is not None:
|
|
83
|
+
if prev <= ref < price:
|
|
84
|
+
msg = f"价格上穿 {ref}({prev} -> {price})"
|
|
85
|
+
elif prev >= ref > price:
|
|
86
|
+
msg = f"价格下穿 {ref}({prev} -> {price})"
|
|
87
|
+
elif rule.type == "vol_spike":
|
|
88
|
+
vol = q.get("vol")
|
|
89
|
+
if vol:
|
|
90
|
+
h = rule.vol_hist.setdefault(code, [])
|
|
91
|
+
h.append(vol)
|
|
92
|
+
if len(h) > rule.window:
|
|
93
|
+
h.pop(0)
|
|
94
|
+
if len(h) >= max(3, rule.window // 2):
|
|
95
|
+
avg = sum(h[:-1]) / len(h[:-1])
|
|
96
|
+
if avg > 0 and vol >= avg * rule.ratio:
|
|
97
|
+
msg = f"成交量 {vol:.0f} 为前{len(h)-1}根均值 {avg:.0f} 的 {vol/avg:.1f} 倍"
|
|
98
|
+
|
|
99
|
+
if msg:
|
|
100
|
+
rule._fire(code)
|
|
101
|
+
if rule.type == "change_dir":
|
|
102
|
+
rule.vol_hist[code] = [price] # 重置基准,避免同一方向重复触发
|
|
103
|
+
return f"[{rule.name}] {code} {msg}(现价 {price})"
|
|
104
|
+
return None
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tdxalert
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: 盘中预警推送引擎:可插拔数据源 + 基础规则集 + 微信推送,零依赖
|
|
5
|
+
Author: mengyiheng
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: stock,alert,quant,wechat,monitor
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Intended Audience :: Financial and Insurance Industry
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Office/Business :: Financial :: Investment
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
<p align="center"><img src="assets/cover.png" alt="tdxalert" width="720"/></p>
|
|
20
|
+
|
|
21
|
+
# tdxalert — 盘中预警推送引擎
|
|
22
|
+
|
|
23
|
+
把你的**全市场实时快照**接进来,按规则监控,触发即推送**微信**。
|
|
24
|
+
|
|
25
|
+
- 🔌 **数据源可插拔**:引擎只吃标准化的快照帧——你自己的采集器、pytdx、
|
|
26
|
+
[tdxreplay](https://gitee.com/mengyiheng/tdxreplay) 回放流,都能接
|
|
27
|
+
- 📏 **基础规则集**:价格上/下破、涨跌幅超阈、成交量异常放大、参考线上穿/下穿,
|
|
28
|
+
每条规则独立冷却时间
|
|
29
|
+
- 💬 **微信推送**:Server酱 / PushPlus,另附钉钉/企微 webhook 与控制台渠道
|
|
30
|
+
- 🪶 **零第三方依赖**,纯 Python 标准库
|
|
31
|
+
- 🧪 规则引擎与推送解耦,方便先接回放源**离线验证预警逻辑**再上实盘
|
|
32
|
+
|
|
33
|
+
## 安装
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install tdxalert
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## 快速开始
|
|
40
|
+
|
|
41
|
+
### 1. 写配置 `alert.json`
|
|
42
|
+
|
|
43
|
+
```json
|
|
44
|
+
{
|
|
45
|
+
"notifiers": { "serverchan": { "key": "SCTxxxxxxxx 你的SendKey" } },
|
|
46
|
+
"rules": [
|
|
47
|
+
{ "type": "price_above", "code": "600519", "value": 1500, "cooldown": 600 },
|
|
48
|
+
{ "type": "pct_below", "code": "*", "value": -3 },
|
|
49
|
+
{ "type": "vol_spike", "code": "*", "window": 20, "ratio": 3 },
|
|
50
|
+
{ "type": "change_dir", "code": "000001", "value": 10.5 }
|
|
51
|
+
]
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### 2. 把你的快照流接进来(每行一帧 JSON)
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
{"600519": {"price": 1501.2, "pct": 1.2, "vol": 3200, "name": "贵州茅台"}, "000001": {...}}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
your_snapshot_source | tdxalert run --config alert.json
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### 3. 或者只用 Python API
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
from tdxalert import AlertEngine
|
|
69
|
+
|
|
70
|
+
engine = AlertEngine.from_config(cfg)
|
|
71
|
+
for frame in my_snapshot_loop(): # 你的 REA / 采集器
|
|
72
|
+
engine.feed(frame) # 触发即推微信
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## 规则类型
|
|
76
|
+
|
|
77
|
+
| type | 含义 | 专属参数 |
|
|
78
|
+
|---|---|---|
|
|
79
|
+
| `price_above` / `price_below` | 价格上破 / 下破 | `value` 阈值 |
|
|
80
|
+
| `pct_above` / `pct_below` | 涨跌幅超阈(%) | `value` |
|
|
81
|
+
| `vol_spike` | 成交量较滚动均值放大 | `window` 窗口根数、`ratio` 倍数 |
|
|
82
|
+
| `change_dir` | 上穿 / 下穿参考线 | `value` 参考价 |
|
|
83
|
+
|
|
84
|
+
通用参数:`code`(`"*"`=全市场)、`cooldown` 冷却秒数(默认 300)。
|
|
85
|
+
|
|
86
|
+
## 快照帧字段约定
|
|
87
|
+
|
|
88
|
+
| 字段 | 必需 | 说明 |
|
|
89
|
+
|---|---|---|
|
|
90
|
+
| `price` | ✅ | 最新价 |
|
|
91
|
+
| `pct` | 建议 | 涨跌幅 %(正涨负跌) |
|
|
92
|
+
| `vol` | 建议 | 成交量(vol_spike 需要) |
|
|
93
|
+
| `name` | 可选 | 证券名称(出现在告警文案里) |
|
|
94
|
+
|
|
95
|
+
## 提示
|
|
96
|
+
|
|
97
|
+
- **先离线验证再上实盘**:把 [tdxreplay](https://gitee.com/mengyiheng/tdxreplay)
|
|
98
|
+
的回放流转成快照帧喂进来,历史数据上确认规则触发符合预期后再接实时源;
|
|
99
|
+
- Server酱 SendKey 获取:<https://sct.ftqq.com/>(微信扫码即用);
|
|
100
|
+
- 规则误报多就加大 `cooldown`;放量告警灵敏度高,`window` 建议不低于 10。
|
|
101
|
+
|
|
102
|
+
## 免责声明
|
|
103
|
+
|
|
104
|
+
本项目仅为个人研究工具,不构成任何投资建议;告警可能延迟、丢失或误报。
|
|
105
|
+
使用前请[阅读完整免责声明](DISCLAIMER.md)。
|
|
106
|
+
|
|
107
|
+
## License
|
|
108
|
+
|
|
109
|
+
MIT
|
|
110
|
+
|
|
111
|
+
## 量化相关定制需求
|
|
112
|
+
|
|
113
|
+
预警引擎恰恰是最值得按个人习惯定制的模块:
|
|
114
|
+
|
|
115
|
+
| 方向 | 示例 |
|
|
116
|
+
|---|---|
|
|
117
|
+
| 📏 规则扩展 | 接入你的策略条件、多规则组合、板块/概念联动预警 |
|
|
118
|
+
| 💬 渠道扩展 | Telegram/邮件/短信、按告警级别分流推送 |
|
|
119
|
+
| 🔌 源适配 | 把你现有的采集器/行情源接成标准快照帧 |
|
|
120
|
+
|
|
121
|
+
**联系合作**:[Gitee Issues](https://gitee.com/mengyiheng/tdxalert/issues) 或 [Gitee 私信](https://gitee.com/mengyiheng)。规则类需求请附:监控对象、触发条件、期望推送方式。
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/tdxalert/__init__.py
|
|
5
|
+
src/tdxalert/__main__.py
|
|
6
|
+
src/tdxalert/cli.py
|
|
7
|
+
src/tdxalert/engine.py
|
|
8
|
+
src/tdxalert/notify.py
|
|
9
|
+
src/tdxalert/rules.py
|
|
10
|
+
src/tdxalert.egg-info/PKG-INFO
|
|
11
|
+
src/tdxalert.egg-info/SOURCES.txt
|
|
12
|
+
src/tdxalert.egg-info/dependency_links.txt
|
|
13
|
+
src/tdxalert.egg-info/entry_points.txt
|
|
14
|
+
src/tdxalert.egg-info/top_level.txt
|
|
15
|
+
tests/test_alert.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
tdxalert
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from tdxalert import AlertEngine, parse
|
|
6
|
+
from tdxalert.notify import ConsoleNotifier
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _engine(rules, sent):
|
|
10
|
+
e = AlertEngine(rules, {})
|
|
11
|
+
e.notifiers = {"console": ConsoleNotifier()}
|
|
12
|
+
e.sent = sent
|
|
13
|
+
return e
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_price_threshold_and_cooldown():
|
|
17
|
+
cfg = [{"type": "price_above", "code": "600519", "value": 1500, "cooldown": 300}]
|
|
18
|
+
e = _engine([parse(c) for c in cfg], [])
|
|
19
|
+
q = {"600519": {"price": 1501, "name": "贵州茅台"}}
|
|
20
|
+
assert len(e.feed(q)) == 1
|
|
21
|
+
assert len(e.feed(q)) == 0 # 冷却期内不重复
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_pct_and_vol_spike():
|
|
25
|
+
cfg = [
|
|
26
|
+
{"type": "pct_below", "code": "*", "value": -2},
|
|
27
|
+
{"type": "vol_spike", "code": "000001", "window": 4, "ratio": 3},
|
|
28
|
+
]
|
|
29
|
+
e = _engine([parse(c) for c in cfg], [])
|
|
30
|
+
base = {"000001": {"price": 10, "pct": -3, "vol": 100}}
|
|
31
|
+
assert len(e.feed(base)) == 1 # 跌幅触发
|
|
32
|
+
for _ in range(3): # 平量若干根
|
|
33
|
+
e.feed({"000001": {"price": 10, "pct": 0, "vol": 100}})
|
|
34
|
+
alerts = e.feed({"000001": {"price": 10, "pct": 0, "vol": 500}})
|
|
35
|
+
assert any("成交量" in a for a in alerts) # 5 倍放量触发
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_change_dir_resets_baseline():
|
|
39
|
+
e = _engine([parse({"type": "change_dir", "code": "600519", "value": 10, "cooldown": 0})], [])
|
|
40
|
+
e.feed({"600519": {"price": 9}})
|
|
41
|
+
assert len(e.feed({"600519": {"price": 11}})) == 1 # 上穿
|
|
42
|
+
assert len(e.feed({"600519": {"price": 11}})) == 0 # 不在同一方向重复
|
|
43
|
+
assert len(e.feed({"600519": {"price": 9}})) == 1 # 回落再次下穿
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_wildcard_and_name_in_message():
|
|
47
|
+
e = _engine([parse({"type": "price_below", "code": "*", "value": 5})], [])
|
|
48
|
+
alerts = e.feed({"000001": {"price": 4.9, "name": "平安银行"}})
|
|
49
|
+
assert alerts and "平安银行" in alerts[0]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_config_load():
|
|
53
|
+
cfg = {
|
|
54
|
+
"notifiers": {"console": {}},
|
|
55
|
+
"rules": [{"type": "price_above", "code": "600519", "value": 1}],
|
|
56
|
+
}
|
|
57
|
+
e = AlertEngine.from_config(cfg)
|
|
58
|
+
assert len(e.rules) == 1 and "console" in e.notifiers
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_unknown_rule_field_rejected():
|
|
62
|
+
with pytest.raises(ValueError):
|
|
63
|
+
parse({"type": "price_above", "code": "600519", "value": 1, "foo": 2})
|