Skip to content

Commit 9dc50ca

Browse files
committed
Add optional advisory Telegram notification
1 parent dce6201 commit 9dc50ca

8 files changed

Lines changed: 252 additions & 0 deletions

File tree

.github/workflows/publish_advisory_site.yml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ jobs:
6868
working-directory: advisor
6969
run: python -m pip install -e .
7070
- name: Build report and static site
71+
id: build_report
7172
working-directory: advisor
7273
env:
7374
INPUT_AS_OF: ${{ github.event.inputs.as_of || '' }}
@@ -101,6 +102,8 @@ jobs:
101102
cp "data/output/published/advisory_report_${AS_OF}.json" site/
102103
cp "data/output/published/advisory_report_${AS_OF}.md" site/
103104
cp "data/output/published/advisory_report_${AS_OF}.json.manifest.json" site/
105+
echo "as_of=${AS_OF}" >> "$GITHUB_OUTPUT"
106+
echo "report_path=data/output/published/advisory_report_${AS_OF}.json" >> "$GITHUB_OUTPUT"
104107
- name: Upload site artifact
105108
uses: actions/upload-artifact@v7
106109
with:
@@ -116,3 +119,17 @@ jobs:
116119
- name: Deploy to GitHub Pages
117120
id: deployment
118121
uses: actions/deploy-pages@v5
122+
- name: Notify Telegram subscribers
123+
if: ${{ success() }}
124+
continue-on-error: true
125+
working-directory: advisor
126+
env:
127+
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
128+
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
129+
SITE_URL: ${{ github.event.inputs.site_url || 'https://quantstrategylab.github.io/QuantAdvisorResearch' }}
130+
REPORT_PATH: ${{ steps.build_report.outputs.report_path }}
131+
run: |
132+
set -euo pipefail
133+
python scripts/notify_advisory_telegram.py \
134+
--report "${REPORT_PATH}" \
135+
--site-url "${SITE_URL}"

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,15 @@ This repository does not own:
4444
- model provider routing or prompt execution
4545
- raw paid market-data redistribution
4646

47+
## AI Usage
48+
49+
This repository does not call Codex, OpenAI, Anthropic, or any other model API.
50+
It only consumes saved `mode=shadow` artifacts from `AiLongHorizonSignalPipelines`.
51+
The only repository in this three-repo flow that can involve AI is
52+
`AiLongHorizonSignalPipelines`, and even there provider execution is delegated to
53+
`QuantStrategyLab/CodexAuditBridge`. Model API keys and fallback routing belong
54+
there, not in this repository.
55+
4756
## Local Example
4857

4958
```bash
@@ -82,6 +91,13 @@ python scripts/build_advisory_report.py \
8291
schedule and uploads the report as a GitHub Actions artifact. It does not commit
8392
files, create orders, or notify investors.
8493

94+
`.github/workflows/publish_advisory_site.yml` publishes the HTML/JSON/RSS site on
95+
a weekly schedule. If `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_ID` are configured
96+
as repository secrets, the workflow sends a short non-personalized Telegram
97+
summary after a successful Pages deployment. If either secret is missing, the
98+
notification step is skipped without failing the publication. Telegram delivery
99+
errors are logged and do not block Pages/RSS output.
100+
85101
Publish a static HTML + RSS preview:
86102

87103
```bash
@@ -110,6 +126,7 @@ gh workflow run "Publish Model Recommendations Site" \
110126
```
111127

112128
Notification channel rules are documented in
129+
[`docs/notification_format.md`](docs/notification_format.md) and
113130
[`docs/notification_format.zh-CN.md`](docs/notification_format.zh-CN.md).
114131

115132
## Output Contract

README.zh-CN.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,12 @@ manifest 会记录 JSON/Markdown 的 SHA256、`as_of`、cadence、来源 artifac
7676
- 模型 provider 路由或 prompt 执行
7777
- 付费行情原始数据再分发
7878

79+
## AI 使用边界
80+
81+
本仓库不直接调用 Codex、OpenAI、Anthropic 或其他模型 API。它只读取 `AiLongHorizonSignalPipelines` 已保存的 `mode=shadow` artifact。
82+
83+
这三个仓库里,只有 `AiLongHorizonSignalPipelines` 的月度 shadow signal 流程会涉及 AI,而且模型执行也委托给 `QuantStrategyLab/CodexAuditBridge`。模型 API key 和 fallback provider routing 都应集中在那里,不应放到本仓库。
84+
7985
## 合规提醒
8086

8187
“不下单、不管仓位”会降低执行风险,但不自动消除投顾/推荐监管风险。只要面向投资者提供具体证券推荐,在不同商业模式下仍可能触发投资顾问、经纪推荐、适当性或 Reg BI 义务。
@@ -102,6 +108,8 @@ python -m pytest -q
102108

103109
它不会提交文件、不会通知投资者、不会创建订单。
104110

111+
`.github/workflows/publish_advisory_site.yml` 会每周发布 HTML/JSON/RSS 站点。如果仓库 secrets 配置了 `TELEGRAM_BOT_TOKEN``TELEGRAM_CHAT_ID`,Pages 部署成功后会发送一条非个性化 Telegram 摘要;如果没配置,通知步骤会跳过;Telegram 发送异常会记录在日志里,但不阻断网页/RSS 发布。
112+
105113
## RSS / 静态页面
106114

107115
生成 HTML + RSS 预览:

docs/notification_format.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# Model Recommendation Notification Format
2+
3+
## Implemented Channels
4+
5+
- GitHub Pages: `.github/workflows/publish_advisory_site.yml` publishes HTML,
6+
JSON, Markdown, and RSS.
7+
- RSS: `feed.xml` is generated by `scripts/publish_advisory_site.py`.
8+
- Telegram: optional. If `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_ID` are set as
9+
repository secrets, `scripts/notify_advisory_telegram.py` sends a short summary
10+
after a successful Pages deployment. Missing secrets skip notification without
11+
failing the publish job.
12+
13+
## Boundary
14+
15+
Notifications may show symbols, recommendation tiers, horizons, source-event
16+
counts, top themes, scores, reasons, risks, and report links. They must not show
17+
orders, target weights, target share quantities, account suitability, or any
18+
account-specific allocation advice.
19+
20+
## Telegram Summary Shape
21+
22+
```text
23+
Quant Model Recommendations | Weekly | 2026-05-31
24+
25+
Mode: model_recommendations
26+
Source: operator_supplied
27+
Source events: 8
28+
29+
Top themes:
30+
- #1 hbm_memory score=2.16 symbols=MU
31+
32+
Recommendations:
33+
- VRT | 二级推荐 | 重点推荐 | 中线 | score=0.72
34+
35+
Policy: non-personalized model output only; no execution, allocation, or account-specific advice.
36+
Full report: https://quantstrategylab.github.io/QuantAdvisorResearch/...
37+
```
38+
39+
## RSS Summary Shape
40+
41+
RSS remains intentionally short for feed readers:
42+
43+
```text
44+
Mode=model_recommendations; source=operator_supplied; themes=...; recommended=....
45+
Non-personalized model output; no execution, allocation, or account-specific advice.
46+
```

docs/notification_format.zh-CN.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,3 +141,11 @@ Subject: Quant Model Recommendations Weekly Review - 2026-05-30
141141
- 短线、中线、长线。
142142
- 模型分数、证据分数、风险分数。
143143
- 推荐理由、来源核验、估值复核、财报复核。
144+
145+
## 当前已实现渠道
146+
147+
- GitHub Pages:`.github/workflows/publish_advisory_site.yml` 发布 HTML、JSON、Markdown 和 RSS。
148+
- RSS:`scripts/publish_advisory_site.py` 生成 `feed.xml`
149+
- Telegram:可选。如果仓库 secrets 配置了 `TELEGRAM_BOT_TOKEN``TELEGRAM_CHAT_ID``scripts/notify_advisory_telegram.py` 会在 Pages 部署成功后发送短摘要;如果缺少任一 secret,会跳过通知但不让发布失败。
150+
151+
通知仍然只能包含标的、推荐层级、周期、来源事件数、主题动量、模型分数、理由、风险和完整报告链接;不能包含订单、目标仓位、目标股数、账户适当性或账户级配置建议。
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
#!/usr/bin/env python3
2+
from __future__ import annotations
3+
4+
import argparse
5+
import json
6+
import os
7+
from pathlib import Path
8+
import sys
9+
10+
ROOT = Path(__file__).resolve().parents[1]
11+
sys.path.insert(0, str(ROOT / "src"))
12+
13+
from quant_advisor_research.notifications import format_telegram_message, send_telegram_message # noqa: E402
14+
15+
16+
def main(argv: list[str] | None = None) -> int:
17+
parser = argparse.ArgumentParser(description="Send a non-personalized advisory report summary to Telegram.")
18+
parser.add_argument("--report", required=True, help="Advisory report JSON path")
19+
parser.add_argument("--site-url", default="https://quantstrategylab.github.io/QuantAdvisorResearch")
20+
parser.add_argument("--dry-run", action="store_true", help="Print the message instead of sending")
21+
args = parser.parse_args(argv)
22+
23+
report = json.loads(Path(args.report).read_text(encoding="utf-8"))
24+
message = format_telegram_message(report, site_url=args.site_url)
25+
if args.dry_run:
26+
print(message)
27+
return 0
28+
29+
token = os.environ.get("TELEGRAM_BOT_TOKEN", "")
30+
chat_id = os.environ.get("TELEGRAM_CHAT_ID", "")
31+
if not token or not chat_id:
32+
print("Telegram notification skipped: TELEGRAM_BOT_TOKEN or TELEGRAM_CHAT_ID is not configured.")
33+
return 0
34+
35+
result = send_telegram_message(bot_token=token, chat_id=chat_id, text=message)
36+
if result.get("ok") is not True:
37+
raise RuntimeError("Telegram API did not acknowledge the message")
38+
print("Telegram notification sent.")
39+
return 0
40+
41+
42+
if __name__ == "__main__":
43+
raise SystemExit(main())
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
from __future__ import annotations
2+
3+
import json
4+
from typing import Any
5+
from urllib.parse import quote
6+
from urllib.request import Request, urlopen
7+
8+
from .publisher import report_filename
9+
10+
11+
def report_public_url(report: dict[str, Any], *, site_url: str) -> str:
12+
return f"{site_url.rstrip('/')}/{quote(report_filename(report))}"
13+
14+
15+
def _format_themes(report: dict[str, Any], *, limit: int) -> list[str]:
16+
theme_momentum = report.get("theme_momentum", {})
17+
if not theme_momentum.get("available"):
18+
return ["- Themes: not available"]
19+
lines = ["Top themes:"]
20+
for theme in theme_momentum.get("top_themes", [])[:limit]:
21+
symbols = ", ".join(theme.get("top_symbols", [])[:5]) or "None"
22+
lines.append(f"- #{theme.get('rank')} {theme.get('theme_id')} score={theme.get('momentum_score')} symbols={symbols}")
23+
return lines
24+
25+
26+
def _format_recommendations(report: dict[str, Any], *, limit: int) -> list[str]:
27+
recommendations = report.get("recommendations", [])
28+
publishable = [
29+
item
30+
for item in recommendations
31+
if item.get("recommendation_tier") in {"tier_1", "tier_2", "watchlist", "source_check"}
32+
][:limit]
33+
if not publishable:
34+
return ["Recommendations: None promoted; review full report for monitor list."]
35+
lines = ["Recommendations:"]
36+
for item in publishable:
37+
lines.append(
38+
"- {symbol} | {tier} | {rating} | {horizon} | score={score}".format(
39+
symbol=item.get("symbol", ""),
40+
tier=item.get("recommendation_tier_label", item.get("recommendation_tier", "")),
41+
rating=item.get("rating_label", item.get("rating", "")),
42+
horizon=item.get("primary_horizon_label", ""),
43+
score=item.get("score", ""),
44+
)
45+
)
46+
return lines
47+
48+
49+
def format_telegram_message(
50+
report: dict[str, Any],
51+
*,
52+
site_url: str,
53+
max_recommendations: int = 5,
54+
max_themes: int = 5,
55+
) -> str:
56+
summary = report.get("summary", {})
57+
lines = [
58+
f"Quant Model Recommendations | {str(report.get('cadence', '')).title()} | {report.get('as_of', '')}",
59+
"",
60+
f"Mode: {report.get('mode', '')}",
61+
f"Source: {summary.get('source_mode', 'unknown')}",
62+
f"Source events: {summary.get('source_event_count', 0)}",
63+
"",
64+
*_format_themes(report, limit=max_themes),
65+
"",
66+
*_format_recommendations(report, limit=max_recommendations),
67+
"",
68+
"Policy: non-personalized model output only; no execution, allocation, or account-specific advice.",
69+
f"Full report: {report_public_url(report, site_url=site_url)}",
70+
]
71+
return "\n".join(str(line) for line in lines if line is not None)
72+
73+
74+
def send_telegram_message(*, bot_token: str, chat_id: str, text: str, timeout: int = 20) -> dict[str, Any]:
75+
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
76+
payload = json.dumps(
77+
{
78+
"chat_id": chat_id,
79+
"text": text,
80+
"disable_web_page_preview": True,
81+
}
82+
).encode("utf-8")
83+
request = Request(url, data=payload, headers={"Content-Type": "application/json"}, method="POST")
84+
with urlopen(request, timeout=timeout) as response: # noqa: S310 - fixed Telegram API endpoint.
85+
body = response.read().decode("utf-8")
86+
return json.loads(body) if body else {}

tests/test_publisher.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,3 +82,30 @@ def test_render_report_html_includes_theme_momentum_context() -> None:
8282
assert "Theme Momentum" in html
8383
assert "hbm_memory" in html
8484
assert "MU" in html
85+
86+
87+
def test_format_telegram_message_contains_themes_policy_and_link() -> None:
88+
from quant_advisor_research.notifications import format_telegram_message
89+
90+
report = build_sample_report()
91+
report["summary"]["top_theme_ids"] = ["hbm_memory"]
92+
report["theme_momentum"] = {
93+
"available": True,
94+
"as_of": "2026-05-30",
95+
"top_themes": [
96+
{
97+
"rank": 1,
98+
"theme_id": "hbm_memory",
99+
"momentum_score": 0.9,
100+
"top_symbols": ["MU"],
101+
}
102+
],
103+
}
104+
105+
message = format_telegram_message(report, site_url="https://example.com/advisor")
106+
107+
assert "Quant Model Recommendations" in message
108+
assert "hbm_memory" in message
109+
assert "MU" in message
110+
assert "no execution" in message
111+
assert "https://example.com/advisor/2026-05-30-weekly-model-recommendations.html" in message

0 commit comments

Comments
 (0)