mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 22:34:18 +08:00
后端: - 监控规则新增 scope=watchlist_group: 规则只存 group_id, 引擎按版本号缓存的 分组成员动态解析, 分组增删标的自动同步监控范围, 无需改规则 - 分组删除 fail-closed (规则暂停+runtime_warning), API 层校验分组存在性 - watchlist 服务增加数据版本号 _REVISION, 读取免锁、写后立即失效引擎缓存 - stock-sdk 插件 display_name 去掉括号合规备注 (说明保留在 description) 前端: - RuleEditor 分组模式交互 (选择分组/成员预览/动态绑定提示), Monitor 列表分组摘要 - 自选搜索: Plus 快速加当前分组 + FolderPlus 展开分组菜单; 多分组圆点叠瓦显示 - 搜索下拉加宽防名称截断, 创/科/ETF 标签紧贴名称 - 自选筛选新增「排除ST」开关 (默认关, 持久化) - 个股详情新增异动信息条: 状态着色/窗口偏离/接近度/计算时间 (无数据不显示) - 引导页: 能力探测按数据源分流 (仅选 TickFlow 才显示 Key/档位), 切换 loading 收进卡片 - 看板空态/首次弹窗按数据源分流文案, 去除 Key/None 档引导注册表述 - 侧边栏实时行情无权限态改为「不可用 + 去配置数据源」 - 设置-数据源页: 新增插件化与配置文档说明块, 内置/插件标识统一改为第三方 测试: test_monitor_group_scope.py 8 例 (动态成员/删除分组/校验/API), 全量 946 passed
184 lines
7.2 KiB
Python
184 lines
7.2 KiB
Python
"""监控规则 scope=watchlist_group — 自选分组动态作用域。
|
|
|
|
覆盖: 规则校验/normalize、引擎按分组当前成员过滤 (分组增删自选后无需改规则,
|
|
下一轮评估自动生效)、分组删除 fail-closed、异动规则分组过滤、API 保存时
|
|
分组存在性校验与列表 runtime_warning。
|
|
"""
|
|
from types import SimpleNamespace
|
|
from unittest.mock import MagicMock
|
|
|
|
import polars as pl
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
|
|
from app.api import monitor_rules as monitor_rules_api
|
|
from app.config import settings
|
|
from app.services import watchlist
|
|
from app.strategy import monitor_rules
|
|
from app.strategy.monitor import MonitorRuleEngine
|
|
|
|
|
|
def _group_rule(rid="r_grp", group_id="g1", **overrides):
|
|
rule = {
|
|
"id": rid, "name": rid, "type": "signal", "asset_type": "stock",
|
|
"scope": "watchlist_group", "group_id": group_id, "symbols": [],
|
|
"logic": "or",
|
|
"conditions": [{"field": "rsi_14", "op": "<", "value": 100}],
|
|
"cooldown_seconds": 0, "enabled": True,
|
|
}
|
|
rule.update(overrides)
|
|
return rule
|
|
|
|
|
|
def _stock_df():
|
|
return pl.DataFrame({
|
|
"symbol": ["600000.SH", "000001.SZ", "300750.SZ"],
|
|
"name": ["浦发银行", "平安银行", "宁德时代"],
|
|
"close": [10.0, 12.0, 200.0],
|
|
"change_pct": [1.0, 2.0, 3.0],
|
|
"rsi_14": [40.0, 50.0, 60.0],
|
|
})
|
|
|
|
|
|
# ── 校验与 normalize ─────────────────────────────────────
|
|
|
|
def test_group_scope_validation():
|
|
with pytest.raises(ValueError, match="自选分组"):
|
|
monitor_rules.validate(_group_rule(group_id=None))
|
|
with pytest.raises(ValueError, match="自选分组"):
|
|
monitor_rules.validate(_group_rule(group_id=" "))
|
|
with pytest.raises(ValueError, match="仅支持个股"):
|
|
monitor_rules.validate(_group_rule(asset_type="etf"))
|
|
# 分时穿越信号仅支持指定标的 (沿用既有限制)
|
|
with pytest.raises(ValueError, match="分时穿越"):
|
|
monitor_rules.validate(_group_rule(
|
|
conditions=[{"field": "signal_intraday_avg_cross_up", "op": "truth"}],
|
|
))
|
|
monitor_rules.validate(_group_rule()) # 合法
|
|
|
|
|
|
def test_normalize_group_scope_fields():
|
|
# 分组作用域: 保留 group_id, 清掉 symbols (成员动态来自分组)
|
|
r = monitor_rules.normalize(_group_rule(symbols=["600000.SH"]))
|
|
assert r["group_id"] == "g1"
|
|
assert r["symbols"] == []
|
|
# 非分组作用域: 清掉残留 group_id
|
|
r = monitor_rules.normalize(_group_rule(scope="all"))
|
|
assert r["group_id"] is None
|
|
|
|
|
|
# ── 引擎: 动态成员过滤 ───────────────────────────────────
|
|
|
|
def test_engine_group_scope_dynamic_members(monkeypatch, tmp_path):
|
|
"""分组内后续加入的标的, 无需修改规则即自动进入监控范围。"""
|
|
monkeypatch.setattr(settings, "data_dir", tmp_path)
|
|
_, group = watchlist.create_group("核心池")
|
|
gid = group["id"]
|
|
watchlist.add("600000.SH", group_id=gid)
|
|
watchlist.add("000001.SZ", group_id=gid)
|
|
|
|
eng = MonitorRuleEngine()
|
|
eng.set_rules([_group_rule(group_id=gid)])
|
|
df = _stock_df()
|
|
|
|
events = eng.evaluate(df)
|
|
assert {e["symbol"] for e in events} == {"600000.SH", "000001.SZ"}
|
|
|
|
# 分组新增宁德时代 → 同一条规则下一轮自动覆盖 (版本号缓存立即失效)
|
|
watchlist.add("300750.SZ", group_id=gid)
|
|
events = eng.evaluate(df)
|
|
assert "300750.SZ" in {e["symbol"] for e in events}
|
|
|
|
# 移出分组 → 自动退出监控范围
|
|
watchlist.remove_from_group("300750.SZ", gid)
|
|
events = eng.evaluate(df)
|
|
assert "300750.SZ" not in {e["symbol"] for e in events}
|
|
|
|
|
|
def test_engine_group_scope_missing_group_fail_closed(monkeypatch, tmp_path):
|
|
"""分组已删除: 不崩、不触发、绝不退化为全市场。"""
|
|
monkeypatch.setattr(settings, "data_dir", tmp_path)
|
|
watchlist.create_group("核心池") # 让分组文件存在, 但规则绑定的 id 不在其中
|
|
|
|
eng = MonitorRuleEngine()
|
|
eng.set_rules([_group_rule(group_id="ghost")])
|
|
assert eng.evaluate(_stock_df()) == []
|
|
|
|
|
|
def test_engine_group_scope_empty_group(monkeypatch, tmp_path):
|
|
monkeypatch.setattr(settings, "data_dir", tmp_path)
|
|
_, group = watchlist.create_group("空组")
|
|
|
|
eng = MonitorRuleEngine()
|
|
eng.set_rules([_group_rule(group_id=group["id"])])
|
|
assert eng.evaluate(_stock_df()) == []
|
|
|
|
|
|
def test_abnormal_group_scope_filtering(monkeypatch, tmp_path):
|
|
monkeypatch.setattr(settings, "data_dir", tmp_path)
|
|
_, group = watchlist.create_group("异动池")
|
|
gid = group["id"]
|
|
watchlist.add("600000.SH", group_id=gid)
|
|
|
|
def _row(symbol, dev_3d):
|
|
return {
|
|
"symbol": symbol, "name": symbol, "board": "主板", "st": False,
|
|
"close": 10.0, "rt_pct": 1.0,
|
|
"windows": {"3d": {"value": dev_3d, "threshold": 0.2, "closeness": abs(dev_3d) / 0.2}},
|
|
}
|
|
|
|
eng = MonitorRuleEngine()
|
|
eng.set_rules([_group_rule(
|
|
rid="r_ab", group_id=gid, type="abnormal",
|
|
scope="watchlist_group", threshold_pct=70, direction="both",
|
|
conditions=[], symbols=[],
|
|
)])
|
|
high_rows = [_row("600000.SH", 0.16), _row("000001.SZ", 0.18), _row("300750.SZ", 0.19)]
|
|
low_rows = [_row("600000.SH", 0.10), _row("000001.SZ", 0.05), _row("300750.SZ", 0.05)]
|
|
# 边缘触发: 首轮观测不告警, 回落置 False 后再次上穿才触发
|
|
eng.evaluate_abnormal(low_rows)
|
|
events = eng.evaluate_abnormal(high_rows)
|
|
# 只有分组内的 600000.SH 触发; 组外两只偏离更高也不会告警
|
|
assert [e["symbol"] for e in events] == ["600000.SH"]
|
|
|
|
|
|
# ── API: 保存校验 + 列表警告 ────────────────────────────
|
|
|
|
def _fake_request(tmp_path):
|
|
repo = MagicMock()
|
|
repo.store.data_dir = tmp_path
|
|
repo.resolve_asset_type.return_value = "stock"
|
|
return SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(repo=repo)))
|
|
|
|
|
|
def test_api_save_rejects_missing_group(monkeypatch, tmp_path):
|
|
monkeypatch.setattr(settings, "data_dir", tmp_path)
|
|
watchlist.create_group("真实分组")
|
|
|
|
req = _fake_request(tmp_path)
|
|
model = monitor_rules_api.RuleModel(**_group_rule(group_id="ghost"))
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
monitor_rules_api.save_rule(model, req)
|
|
assert exc_info.value.status_code == 400
|
|
|
|
|
|
def test_api_save_and_list_group_rule(monkeypatch, tmp_path):
|
|
monkeypatch.setattr(settings, "data_dir", tmp_path)
|
|
_, group = watchlist.create_group("核心池")
|
|
|
|
req = _fake_request(tmp_path)
|
|
model = monitor_rules_api.RuleModel(**_group_rule(group_id=group["id"]))
|
|
resp = monitor_rules_api.save_rule(model, req)
|
|
assert resp["ok"] is True
|
|
assert resp["rule"]["group_id"] == group["id"]
|
|
|
|
listed = monitor_rules_api.list_rules(req)
|
|
rule = next(r for r in listed["rules"] if r["id"] == "r_grp")
|
|
assert "runtime_warning" not in rule
|
|
|
|
# 分组删除后: 列表标注警告 (引擎侧同轮已 fail-closed)
|
|
watchlist.delete_group(group["id"])
|
|
listed = monitor_rules_api.list_rules(req)
|
|
rule = next(r for r in listed["rules"] if r["id"] == "r_grp")
|
|
assert "已删除" in rule["runtime_warning"]
|