Files
tick-stock-panel/backend/app/services/webhook_adapter.py
T
Jinfeng SunandClaude Opus 4.8 e5a94c42d5 feat: ETF 支持(选股 / 回测 / 监控) (#61)
* feat(screener): 选股引擎支持 ETF

- 12 个内置策略打 asset_types 白名单 + strategy_supports_asset;涨停类
  (连板/断板反包)仅股票,其余 10 个技术类对 ETF 开放
- ScreenerService(repo, asset_type) 分流取数,ETF 复用 kline_etf_enriched,
  跳过股票专用历史缓存与涨停信号;进程级 _history_cache key 含 asset_type
- API /run、/run_preset 透传 asset_type;/strategies 按资产过滤;
  股票专有策略在 ETF 下返回空
- 新增 enriched_dirname(asset_type) 共享 helper;get_enriched_latest_asset
  增 refresh 参数(供轮询线程避免冷缓存同步重算)
- 前端「策略」页加 股票/ETF 切换,ETF 走实时单跑(空日期→用 ETF 自身最新日);
  QK.screenerStrategies 按 asset_type keyed
- 测试:test_screener_etf.py

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(backtest): 回测支持 ETF(个股/因子/策略组合)

- 三条回测路径 + 共用 BacktestEngine 面板加载按 asset_type 路由到
  kline_etf_enriched(复用 enriched_dirname);PanelCache key 隔离资产;
  ETF 跳过股票专用 get_enriched_range 缓存
- 面板 compute_all/名称 JOIN 按 asset_type 取维表(get_instruments_asset),
  修复 ETF 策略回测用错股票维表致名称为空/涨停信号算错
- BacktestConfig/FactorConfig/StrategyBacktestConfig 增 asset_type
- 三个回测 API + SSE stream 透传 asset_type;_make_job_key 纳入 asset_type
  (修复 stream 与 cancel job_key 不对齐致取消失效的回归)
- 前端策略组合页/因子页加 股票/ETF 切换,标的搜索与策略列表跟随资产;
  assetType 持久化
- 测试:test_backtest_etf.py(含 job_key 一致性回归);既有回测测试替身同步

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(monitor): 监控规则支持 ETF

- engine.evaluate(df, asset_type) 按规则 asset_type 分轮评估;quote_service
  增开 ETF 评估轮(用 ETF enriched 快照),股票轮不受影响、不重置其策略结果
- ETF 评估轮独立 try(异常不丢弃已算出的股票告警)+ refresh=False(不在轮询
  线程触发 ETF 冷缓存同步重算)
- ETF 版历史加载器(main.py 注入)+ 按规则 asset_type 选加载器
- _strategy_pools 按 (sid, asset_type) 键,避免同策略股票/ETF 规则互相覆盖
- name_map 仅在有 ETF 规则时补 ETF 维表, setdefault 保股票名优先
- RuleModel/normalize 增 asset_type(默认 stock,持久化往返)
- 前端 RuleEditor 加 股票/ETF 选择,策略列表与标的搜索跟随资产
- 测试:test_monitor_etf.py

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(etf): 前端 API 绑定透传 asset_type + 文档

- api.ts: screener/backtest 绑定加 assetType 参数,MonitorRule 类型加 asset_type
- docs/features.md: 标注选股/回测/监控的 ETF 支持范围与前提

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(reliability): 管道并发/原子写/能力探测/监控告警多处加固

后端可靠性专项修复(均带回归测试, backend 全套 64 passed):

并发与数据完整性:
- 盘后管道单飞: JobStore.create() 去重纳入 pending∨running, 关闭"两次快速点击"
  并发双跑窗口; 新增 _heavy_run_lock 执行槽挡住 reap 后僵尸线程并发写 parquet
- adj_factor/minute 全部改走原子写(tmp+replace), 消除 kill/断电致 all.parquet 损坏
- 分块拉取失败聚合 WARNING 可见化(不再静默当成功); 复权失败标的会保持旧价已提示

能力探测:
- 周期重探(60min)热更新 app.state.capabilities, 付费 Key 过期/续费无需重启即可见
- 瞬时探测失败(超时/连接/5xx, 按 _is_transient 判定)不降级、保留旧付费档;
  真 401/无权限仍正常降级回落 free-api

监控告警:
- 评估仅在连续竞价(9:30-11:30/13:00-15:00)+ 快照当日新鲜度下进行, 避开集合竞价/
  收盘后陈旧价与节假日误告警
- scope=sector fail-closed(validate 拒绝新建 + _apply_scope 返回空), 修复板块规则
  对全市场刷屏
- 飞书 webhook 加退避重试并移到独立线程池 fire-and-forget, 不再阻塞行情轮询线程

单标的新鲜度: 新增 repo.symbols_lagging() 检测掉队标的并 WARNING + 计入 job 结果

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 12:12:29 +08:00

313 lines
12 KiB
Python

"""Webhook 推送适配器 — 把告警事件推送到外部 IM / 量化软件。
职责: 把后端产生的告警事件, 通过用户配置的 Webhook 地址推送到外部。
目前支持飞书群机器人; QMT / ptrade 等量化通道为待定。
飞书自定义机器人接入:
1. 飞书群 → 群设置 → 群机器人 → 添加「自定义机器人」
2. 复制生成的 Webhook 地址 (形如 https://open.feishu.cn/open-apis/bot/v2/hook/xxx)
3. (可选) 安全设置 → 启用「签名校验」, 记录签名密钥(secret)
4. 填入设置页「飞书 Webhook」配置
设计: 失败静默降级, 绝不因推送失败阻断告警主流程 (落盘 / SSE 推送)。
去重不在本层做, 复用 MonitorRuleEngine 的 cooldown。
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import logging
import time
logger = logging.getLogger(__name__)
# 单次推送最长字符 (飞书单条文本消息上限 30KB, 这里保守截断避免刷屏)
_MAX_LEN = 500
# 卡片消息正文最长字符 (飞书 interactive 卡片上限 30KB, 保守留余量给标题/结构)
_CARD_MAX_LEN = 28000
# 飞书自定义机器人 Webhook 前缀 (用于 URL 合法性校验)
FEISHU_HOOK_PREFIX = "https://open.feishu.cn/open-apis/bot/v2/hook/"
def _truncate(text: str) -> str:
"""截断超长文本。"""
text = (text or "").strip()
return text[:_MAX_LEN] + ("…" if len(text) > _MAX_LEN else "")
def is_valid_feishu_url(url: str) -> bool:
"""校验是否为合法的飞书自定义机器人 Webhook 地址。"""
return bool(url) and url.startswith(FEISHU_HOOK_PREFIX)
def _gen_sign(timestamp: str, secret: str) -> str:
"""计算飞书自定义机器人签名。
算法 (官方): 把 `timestamp + "\\n" + secret` 作为签名字符串 (key),
用 HmacSHA256 计算空字符串的签名结果, 再 Base64 编码。
"""
string_to_sign = f"{timestamp}\n{secret}"
hmac_code = hmac.new(
string_to_sign.encode("utf-8"),
digestmod=hashlib.sha256,
).digest()
return base64.b64encode(hmac_code).decode("utf-8")
def _truncate_card(text: str) -> str:
"""截断卡片正文 (留余量给标题与卡片结构)。"""
text = (text or "").strip()
return text[:_CARD_MAX_LEN] + ("…" if len(text) > _CARD_MAX_LEN else "")
_FEISHU_MAX_ATTEMPTS = 3
def _post_feishu(webhook_url: str, payload: dict, secret: str) -> bool:
"""发送飞书 webhook 请求并判定成败 (供 text / card 共用)。
成功响应: HTTP 200 且业务 code=0 (或非 JSON/非 dict 的 200)。
瞬时失败 (网络/超时/HTTP 5xx) 会**带退避重试** —— 告警冷却在事件生成时即打戳,
一次瞬时 5xx/timeout 若不重试, 该告警会被冷却窗口(默认 1h)压掉, 离屏用户彻底
收不到推送。永久失败 (4xx / 业务 code≠0, 如签名错、URL 失效) 不重试。最终失败
记 WARNING (而非之前的 debug), 保证「推送丢了」在日志里可见。
"""
import httpx
last_err = ""
for attempt in range(1, _FEISHU_MAX_ATTEMPTS + 1):
try:
# 启用签名校验时, 请求体须带 timestamp + sign (每次重试都重算, 防时间戳过期)
if secret:
timestamp = str(int(time.time()))
payload["timestamp"] = timestamp
payload["sign"] = _gen_sign(timestamp, secret)
resp = httpx.post(webhook_url, json=payload, timeout=5.0)
if resp.status_code == 200:
try:
data = resp.json()
except ValueError:
return True # 非 JSON 的 200, 视为成功
if isinstance(data, dict):
code = data.get("code", data.get("StatusCode", 0))
if code == 0:
return True
# 业务失败(签名错/格式错等): 重试无益, 直接失败
logger.warning("飞书推送业务失败(不重试): %s", data)
return False
return True # 200 且 JSON 非 dict, 视为成功
# 4xx 客户端错误(URL 失效等): 不重试; 5xx: 落入重试
last_err = f"HTTP {resp.status_code}: {resp.text[:200]}"
if resp.status_code < 500:
logger.warning("飞书推送失败(不重试, 客户端错误): %s", last_err)
return False
except Exception as e: # noqa: BLE001 — 网络/超时, 可重试
last_err = str(e)
if attempt < _FEISHU_MAX_ATTEMPTS:
time.sleep(min(2 ** (attempt - 1), 3)) # 退避: 1s, 2s
logger.warning("飞书 Webhook 推送最终失败(已重试 %d 次): %s", _FEISHU_MAX_ATTEMPTS, last_err)
return False
def send_feishu(webhook_url: str, title: str, body: str, secret: str = "") -> bool:
"""推送一条文本消息到飞书群机器人。
Args:
webhook_url: 飞书自定义机器人 Webhook 地址
title: 消息标题 (与正文拼接为一条文本)
body: 消息正文
secret: 签名密钥 (机器人启用了「签名校验」时必填; 留空则不带签名)
Returns:
True=成功送达, False=失败或 URL 非法。
失败静默, 不抛异常 (Webhook 是辅助通道, 不能阻断告警主流程)。
"""
if not is_valid_feishu_url(webhook_url):
return False
text = _truncate(f"{title}\n{body}".strip())
if not text:
return False
payload: dict = {"msg_type": "text", "content": {"text": text}}
return _post_feishu(webhook_url, payload, secret)
def send_feishu_card(webhook_url: str, title: str, subtitle: str, body_md: str, secret: str = "") -> bool:
"""推送一条 interactive 卡片消息到飞书群机器人 —— 用 lark_md 渲染完整 markdown 报告。
飞书「自定义机器人」webhook 不支持文件附件, 但 interactive 卡片的 lark_md 元素
可渲染 markdown, 能承载完整复盘报告(通常 2-5KB, 远小于卡片 30KB 上限)。
Args:
webhook_url: 飞书自定义机器人 Webhook 地址
title: 卡片标题 (显示在蓝色 header)
subtitle: 副标题 (加粗显示, 如日期/情绪标签; 留空则省略)
body_md: 卡片正文 markdown (报告全文)
secret: 签名密钥 (启用签名校验时必填)
Returns:
True=成功送达, False=失败或 URL 非法。
失败静默, 不抛异常 (与 send_feishu 一致, 不阻断告警主流程)。
"""
if not is_valid_feishu_url(webhook_url):
return False
body = _truncate_card(body_md)
elements: list[dict] = []
if subtitle.strip():
elements.append({
"tag": "div",
"text": {"tag": "lark_md", "content": f"**{subtitle.strip()}**"},
})
elements.append({"tag": "hr"})
elements.append({
"tag": "div",
"text": {"tag": "lark_md", "content": body},
})
payload: dict = {
"msg_type": "interactive",
"card": {
"config": {"wide_screen_mode": True},
"header": {
"title": {"tag": "plain_text", "content": title},
"template": "blue",
},
"elements": elements,
},
}
return _post_feishu(webhook_url, payload, secret)
# ================================================================
# 企业微信群机器人
# ================================================================
#
# 与飞书自定义机器人几乎同构: 同样是"群机器人 Webhook + POST JSON"。
# 关键差异:
# 1. Webhook 形态: https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx
# 2. 无需签名校验 (key 本身即凭证; 企业微信群机器人可选"签名校验"但极少用)
# 3. Markdown 原生支持 (msg_type=markdown), 不必像飞书那样包进 interactive 卡片
# 4. 成功响应: {"errcode":0,"errmsg":"ok"}
#
# 限制: 每个机器人每分钟最多 20 条消息 (超出会被限流 460min 内不可用),
# 依赖 MonitorRuleEngine 的 cooldown 去重即可应对告警场景。
# 企业微信群的消息可在绑定的个人微信接收, 实现"微信推送"体验。
WECOM_HOOK_PREFIX = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send"
def is_valid_wecom_url(url: str) -> bool:
"""校验是否为合法的企业微信群机器人 Webhook 地址。
允许两种写法:
- 完整: https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx
- 仅 key: xxx (企业微信群机器人 key 为 36 位 UUID 样式, 保存时自动补全)
"""
if not url:
return False
if url.startswith(WECOM_HOOK_PREFIX):
return True
# 纯 key: 企业微信 key 形如 12345678-1234-1234-1234-1234567890ab (36 位),
# 但用户可能截断, 放宽到 >= 20 位的无空格无斜杠字符串。
url = url.strip()
if " " in url or "/" in url or "?" in url:
return False
return len(url) >= 20
def normalize_wecom_url(url: str) -> str:
"""把纯 key 补全为完整 Webhook URL。已是完整 URL 则原样返回。"""
url = (url or "").strip()
if not url:
return ""
if url.startswith(WECOM_HOOK_PREFIX):
return url
return f"{WECOM_HOOK_PREFIX}?key={url}"
def _post_wecom(webhook_url: str, payload: dict) -> bool:
"""发送一次企业微信 webhook 请求并判定成败。
成功响应: HTTP 200 且 errcode=0。失败静默返回 False。
"""
try:
import httpx
resp = httpx.post(webhook_url, json=payload, timeout=5.0)
if resp.status_code == 200:
try:
data = resp.json()
if isinstance(data, dict):
# errcode=0 表示成功; 45009=频率限制, 其它非零=业务失败
if data.get("errcode") == 0:
return True
logger.debug("企业微信推送业务失败: %s", data)
return False
except ValueError:
return True
logger.debug("企业微信推送 HTTP %s: %s", resp.status_code, resp.text[:200])
return False
except Exception as e: # noqa: BLE001
logger.debug("企业微信 Webhook 推送失败: %s", e)
return False
def send_wecom(webhook_url: str, title: str, body: str) -> bool:
"""推送一条文本消息到企业微信群机器人。
Args:
webhook_url: 企业微信群机器人 Webhook 地址 (或纯 key, 会自动补全)
title: 消息标题 (与正文拼接为一条文本)
body: 消息正文
Returns:
True=成功送达, False=失败或 URL 非法。
失败静默, 不抛异常 (与 send_feishu 一致)。
"""
webhook_url = normalize_wecom_url(webhook_url)
if not is_valid_wecom_url(webhook_url):
return False
text = _truncate(f"{title}\n{body}".strip())
if not text:
return False
payload: dict = {"msg_type": "text", "text": {"content": text}}
return _post_wecom(webhook_url, payload)
def send_wecom_markdown(webhook_url: str, title: str, body_md: str) -> bool:
"""推送一条 Markdown 消息到企业微信群机器人 —— 承载完整复盘报告。
企业微信群机器人原生支持 markdown 类型 (比飞书 interactive 卡片简单),
支持 # ## **粗体** >引用 - 列表 等基础语法, 单条上限 4096 字节。
Args:
webhook_url: 企业微信群机器人 Webhook 地址 (或纯 key)
title: 标题 (作为一级标题 ## 拼到正文前)
body_md: markdown 正文
Returns:
True=成功送达, False=失败或 URL 非法。
"""
webhook_url = normalize_wecom_url(webhook_url)
if not is_valid_wecom_url(webhook_url):
return False
content = f"## {title}\n\n{_truncate_card(body_md)}"
if not content.strip():
return False
payload: dict = {"msg_type": "markdown", "markdown": {"content": content}}
return _post_wecom(webhook_url, payload)