Skip to content

Commit 2b29919

Browse files
committed
Add monthly advisory review artifacts
1 parent 28bd68a commit 2b29919

8 files changed

Lines changed: 398 additions & 7 deletions

File tree

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
name: Monthly Advisory Review
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
as_of:
7+
description: "Report date. Defaults to current UTC date when empty."
8+
required: false
9+
type: string
10+
previous_report_path:
11+
description: "Optional previous advisory report JSON path inside advisor repository checkout."
12+
required: false
13+
default: ""
14+
type: string
15+
political_events_path:
16+
description: "Path inside PoliticalEventTrackingResearch."
17+
required: false
18+
default: "data/live/political_events.csv"
19+
type: string
20+
political_watchlist_path:
21+
description: "Path inside PoliticalEventTrackingResearch."
22+
required: false
23+
default: "data/live/political_watchlist.csv"
24+
type: string
25+
ai_signal_path:
26+
description: "Path inside AiLongHorizonSignalPipelines."
27+
required: false
28+
default: "data/output/latest_signal.json"
29+
type: string
30+
theme_momentum_path:
31+
description: "Optional path inside AiLongHorizonSignalPipelines. Empty disables theme momentum context."
32+
required: false
33+
default: "data/output/theme_momentum_snapshot.json"
34+
type: string
35+
schedule:
36+
- cron: "20 13 1 * *"
37+
38+
jobs:
39+
build-monthly-review:
40+
runs-on: ubuntu-latest
41+
steps:
42+
- name: Checkout advisor repository
43+
uses: actions/checkout@v6
44+
with:
45+
path: advisor
46+
- name: Checkout political event repository
47+
uses: actions/checkout@v6
48+
with:
49+
repository: QuantStrategyLab/PoliticalEventTrackingResearch
50+
path: political-events
51+
- name: Checkout AI shadow repository
52+
uses: actions/checkout@v6
53+
with:
54+
repository: QuantStrategyLab/AiLongHorizonSignalPipelines
55+
path: ai-long-horizon
56+
- uses: actions/setup-python@v6
57+
with:
58+
python-version: "3.11"
59+
- name: Install advisor package
60+
working-directory: advisor
61+
run: python -m pip install -e .
62+
- name: Build monthly report and review artifact
63+
working-directory: advisor
64+
env:
65+
INPUT_AS_OF: ${{ github.event.inputs.as_of || '' }}
66+
PREVIOUS_REPORT_PATH: ${{ github.event.inputs.previous_report_path || '' }}
67+
POLITICAL_EVENTS_PATH: ${{ github.event.inputs.political_events_path || 'data/live/political_events.csv' }}
68+
POLITICAL_WATCHLIST_PATH: ${{ github.event.inputs.political_watchlist_path || 'data/live/political_watchlist.csv' }}
69+
AI_SIGNAL_PATH: ${{ github.event.inputs.ai_signal_path || 'data/output/latest_signal.json' }}
70+
THEME_MOMENTUM_PATH: ${{ github.event.inputs.theme_momentum_path || 'data/output/theme_momentum_snapshot.json' }}
71+
run: |
72+
set -euo pipefail
73+
AS_OF="${INPUT_AS_OF:-$(date -u +%F)}"
74+
mkdir -p data/output/monthly_advisory_review
75+
THEME_ARGS=()
76+
if [ -n "${THEME_MOMENTUM_PATH}" ] && [ -f "../ai-long-horizon/${THEME_MOMENTUM_PATH}" ]; then
77+
THEME_ARGS=(--theme-momentum "../ai-long-horizon/${THEME_MOMENTUM_PATH}")
78+
fi
79+
python scripts/build_advisory_report.py \
80+
--as-of "${AS_OF}" \
81+
--cadence monthly \
82+
--political-events "../political-events/${POLITICAL_EVENTS_PATH}" \
83+
--political-watchlist "../political-events/${POLITICAL_WATCHLIST_PATH}" \
84+
--ai-signal "../ai-long-horizon/${AI_SIGNAL_PATH}" \
85+
"${THEME_ARGS[@]}" \
86+
--output-json "data/output/monthly_advisory_review/advisory_report_${AS_OF}.json" \
87+
--output-md "data/output/monthly_advisory_review/advisory_report_${AS_OF}.md"
88+
REVIEW_ARGS=(
89+
--current-report "data/output/monthly_advisory_review/advisory_report_${AS_OF}.json"
90+
--output-json "data/output/monthly_advisory_review/monthly_review_${AS_OF}.json"
91+
--output-md "data/output/monthly_advisory_review/monthly_review_${AS_OF}.md"
92+
)
93+
if [ -n "${PREVIOUS_REPORT_PATH}" ] && [ -f "${PREVIOUS_REPORT_PATH}" ]; then
94+
REVIEW_ARGS+=(--previous-report "${PREVIOUS_REPORT_PATH}")
95+
fi
96+
python scripts/build_monthly_review.py "${REVIEW_ARGS[@]}"
97+
- name: Upload monthly review artifact
98+
uses: actions/upload-artifact@v7
99+
with:
100+
name: monthly-advisory-review
101+
path: advisor/data/output/monthly_advisory_review/
102+
if-no-files-found: error

README.md

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,9 @@ system:
2121

2222
The current operating cadence is a weekly public recommendation snapshot,
2323
supported by weekly event/theme refreshes and monthly long-horizon AI shadow
24-
context. Monthly review reports can be added later for performance review, but
25-
they should not replace the weekly publication while short-horizon windows are
26-
part of the contract.
24+
context. Monthly review reports are generated separately for change review and
25+
month-end inspection. They do not replace the weekly publication while
26+
short-horizon windows are part of the contract.
2727

2828
Live site:
2929

@@ -107,6 +107,19 @@ windows, so a monthly-only public report would make short-horizon conclusions
107107
stale. The AI shadow input remains monthly because it is long-horizon context,
108108
not a weekly trading signal.
109109

110+
Build the separate monthly review artifact:
111+
112+
```bash
113+
python scripts/build_monthly_review.py \
114+
--current-report data/output/weekly_advisory_review/advisory_report_2026-05-30.json \
115+
--output-json data/output/monthly_advisory_review/monthly_review_2026-05-30.json \
116+
--output-md data/output/monthly_advisory_review/monthly_review_2026-05-30.md
117+
```
118+
119+
`.github/workflows/monthly_advisory_review.yml` runs monthly and uploads the
120+
monthly report/review artifacts only. It is intentionally separate from the
121+
weekly public HTML/RSS publication.
122+
110123
Publish a static HTML + RSS preview:
111124

112125
```bash

README.zh-CN.md

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ QuantStrategyLab 的“智慧顾投”研究协调仓库。它生成非个性化
66

77
线上站点:<https://quantstrategylab.github.io/QuantAdvisorResearch/>
88

9-
当前运行节奏是:**周度公开推荐 + 周度事件/主题刷新 + 月度 AI shadow 背景**后续可以新增月度复盘,用来回顾上月推荐表现,但只要报告里仍保留短线 `1-10个交易日` 和中线 `2-12周` 窗口,公开推荐就不应改成月更。
9+
当前运行节奏是:**周度公开推荐 + 周度事件/主题刷新 + 月度 AI shadow 背景 + 单独月度复盘 artifact**月度复盘只做变化回顾和月末检查;只要报告里仍保留短线 `1-10个交易日` 和中线 `2-12周` 窗口,公开推荐就不应改成月更。
1010

1111
## 仓库定位
1212

@@ -50,7 +50,7 @@ python scripts/build_advisory_report.py \
5050

5151
## 版本管理
5252

53-
- Python 包版本:`0.1.1`
53+
- Python 包版本:`0.1.2`
5454
- 报告 schema:`schema_version = 5`
5555
- 报告 contract:`model_recommendations.v5`
5656
- 报告 manifest:`<output-json>.manifest.json`
@@ -114,6 +114,21 @@ python -m pytest -q
114114

115115
周度发布是有意保留的:短线结论如果只月更会过期;月度 AI shadow 只提供长周期背景,不作为每周追热点的模型输入。
116116

117+
## 月度复盘
118+
119+
`.github/workflows/monthly_advisory_review.yml` 每月生成一次 `monthly_advisory_review` artifact,用来检查本月最终推荐、短/中/长线分布,以及相对上一份报告的新增、移除和保留标的。
120+
121+
本地生成:
122+
123+
```bash
124+
python scripts/build_monthly_review.py \
125+
--current-report data/output/weekly_advisory_review/advisory_report_2026-05-30.json \
126+
--output-json data/output/monthly_advisory_review/monthly_review_2026-05-30.json \
127+
--output-md data/output/monthly_advisory_review/monthly_review_2026-05-30.md
128+
```
129+
130+
如果传入 `--previous-report`,会输出新增、移除和保留标的;不传时仍能生成本月快照,但会记录数据质量提示。这个 workflow 只上传 artifact,不发布网页,也不替代周度公开推荐。
131+
117132
## RSS / 静态页面
118133

119134
生成 HTML + RSS 预览:

docs/system_design.zh-CN.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ broker platform repositories
8989
- `PoliticalEventTrackingResearch`:事件/RSS 事实层周更,必要时手工触发;
9090
- `AiLongHorizonSignalPipelines`:主题动量周更,长周期 AI shadow signal 月更;
9191
- `QuantAdvisorResearch`:公开 HTML/JSON/RSS 推荐继续周更;
92-
- 月度复盘后续可以新增,用来回顾上月推荐表现,不替代周度公开推荐。
92+
- 月度复盘单独生成 artifact,用来回顾本月最终推荐和相对上次变化,不替代周度公开推荐。
9393

9494
## 跨板块长期主题层
9595

scripts/build_monthly_review.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
#!/usr/bin/env python3
2+
from pathlib import Path
3+
import sys
4+
5+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
6+
7+
from quant_advisor_research.monthly_review import main
8+
9+
10+
if __name__ == "__main__":
11+
main()

src/quant_advisor_research/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22

33
__all__ = ["__version__"]
44

5-
__version__ = "0.1.1"
5+
__version__ = "0.1.2"
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
from __future__ import annotations
2+
3+
import argparse
4+
import datetime as dt
5+
import json
6+
from pathlib import Path
7+
from typing import Any
8+
9+
10+
HORIZON_LABELS_ZH = {
11+
"short": "短线",
12+
"medium": "中线",
13+
"long": "长线",
14+
}
15+
16+
HORIZON_WINDOWS_ZH = {
17+
"short": "1-10个交易日",
18+
"medium": "2-12周",
19+
"long": "1-3年",
20+
}
21+
22+
23+
def utc_now_iso() -> str:
24+
return dt.datetime.now(dt.UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z")
25+
26+
27+
def load_report(path: str | Path) -> dict[str, Any]:
28+
with Path(path).open(encoding="utf-8") as handle:
29+
return json.load(handle)
30+
31+
32+
def final_recommendations(report: dict[str, Any]) -> list[dict[str, Any]]:
33+
decisions = report.get("final_decisions")
34+
if not isinstance(decisions, dict):
35+
return []
36+
items = decisions.get("recommendations", [])
37+
return [item for item in items if isinstance(item, dict) and item.get("symbol")]
38+
39+
40+
def symbols(items: list[dict[str, Any]]) -> list[str]:
41+
return [str(item["symbol"]).upper() for item in items]
42+
43+
44+
def horizon_buckets(items: list[dict[str, Any]]) -> dict[str, list[str]]:
45+
buckets = {"short": [], "medium": [], "long": []}
46+
for item in items:
47+
horizon = str(item.get("primary_horizon", ""))
48+
if horizon in buckets:
49+
buckets[horizon].append(str(item["symbol"]).upper())
50+
return buckets
51+
52+
53+
def compact_pick(item: dict[str, Any]) -> dict[str, Any]:
54+
return {
55+
"symbol": str(item.get("symbol", "")).upper(),
56+
"name": str(item.get("name", "")),
57+
"primary_horizon": str(item.get("primary_horizon", "")),
58+
"primary_horizon_label": str(item.get("primary_horizon_label", "")),
59+
"primary_horizon_window": str(item.get("primary_horizon_window", "")),
60+
"combined_score": item.get("combined_score"),
61+
"source_score": item.get("source_score"),
62+
"momentum_score": item.get("momentum_score"),
63+
"ai_signal_score": item.get("ai_signal_score"),
64+
"business_summary": str(item.get("business_summary", "")),
65+
"prospect_summary": str(item.get("prospect_summary", "")),
66+
"risk_summary": str(item.get("risk_summary", "")),
67+
}
68+
69+
70+
def build_monthly_review(
71+
*,
72+
current_report: dict[str, Any],
73+
previous_report: dict[str, Any] | None = None,
74+
current_report_path: str | Path = "",
75+
previous_report_path: str | Path = "",
76+
) -> dict[str, Any]:
77+
current_items = final_recommendations(current_report)
78+
previous_items = final_recommendations(previous_report or {})
79+
current_symbols = symbols(current_items)
80+
previous_symbols = symbols(previous_items)
81+
current_set = set(current_symbols)
82+
previous_set = set(previous_symbols)
83+
data_quality_warnings: list[str] = []
84+
if previous_report is None:
85+
data_quality_warnings.append("No previous report supplied; month-over-month changes are not available.")
86+
if not current_items:
87+
data_quality_warnings.append("Current report has no final recommendations.")
88+
89+
return {
90+
"schema_version": "1",
91+
"mode": "monthly_advisory_review",
92+
"as_of": str(current_report.get("as_of", "")),
93+
"generated_at": utc_now_iso(),
94+
"source_artifacts": {
95+
"current_report": str(current_report_path),
96+
"previous_report": str(previous_report_path) if previous_report_path else "",
97+
},
98+
"summary": {
99+
"current_final_recommendations": current_symbols,
100+
"previous_final_recommendations": previous_symbols,
101+
"added_symbols": sorted(current_set - previous_set),
102+
"removed_symbols": sorted(previous_set - current_set),
103+
"unchanged_symbols": [symbol for symbol in current_symbols if symbol in previous_set],
104+
"current_horizon_buckets": horizon_buckets(current_items),
105+
"data_quality_warnings": data_quality_warnings,
106+
},
107+
"current_recommendations": [compact_pick(item) for item in current_items],
108+
"previous_recommendations": [compact_pick(item) for item in previous_items],
109+
"policy": {
110+
"execution_allowed": False,
111+
"portfolio_allocation_allowed": False,
112+
"personalized_advice_allowed": False,
113+
"downstream_use": "Monthly review of non-personalized model recommendations only.",
114+
},
115+
}
116+
117+
118+
def render_monthly_review_markdown(review: dict[str, Any]) -> str:
119+
lines = [f"# 月度模型推荐复盘 - {review.get('as_of', '')}", ""]
120+
summary = review.get("summary", {})
121+
buckets = summary.get("current_horizon_buckets", {})
122+
lines.append("## 本月最终推荐")
123+
lines.append("")
124+
for horizon in ("short", "medium", "long"):
125+
label = HORIZON_LABELS_ZH[horizon]
126+
window = HORIZON_WINDOWS_ZH[horizon]
127+
value = ", ".join(buckets.get(horizon, [])) or "暂无最终推荐"
128+
lines.append(f"- {label}{window}):{value}")
129+
lines.append("")
130+
lines.append("## 较上次变化")
131+
lines.append("")
132+
lines.append(f"- 新增:{', '.join(summary.get('added_symbols', [])) or '无'}")
133+
lines.append(f"- 移除:{', '.join(summary.get('removed_symbols', [])) or '无'}")
134+
lines.append(f"- 保持:{', '.join(summary.get('unchanged_symbols', [])) or '无'}")
135+
warnings = summary.get("data_quality_warnings", [])
136+
if warnings:
137+
lines.append("")
138+
lines.append("## 数据质量提示")
139+
lines.extend(f"- {warning}" for warning in warnings)
140+
picks = review.get("current_recommendations", [])
141+
if picks:
142+
lines.append("")
143+
lines.append("## 标的摘要")
144+
for item in picks:
145+
lines.extend(
146+
[
147+
"",
148+
f"### {item.get('symbol')} - {item.get('name')}",
149+
f"- 周期:{item.get('primary_horizon_label')}{item.get('primary_horizon_window')})",
150+
f"- 股票背景:{item.get('business_summary')}",
151+
f"- 推荐理由:{item.get('prospect_summary')}",
152+
f"- 主要风险:{item.get('risk_summary')}",
153+
]
154+
)
155+
return "\n".join(lines).rstrip() + "\n"
156+
157+
158+
def write_json(path: str | Path, payload: dict[str, Any]) -> None:
159+
output_path = Path(path)
160+
output_path.parent.mkdir(parents=True, exist_ok=True)
161+
output_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8")
162+
163+
164+
def write_text(path: str | Path, content: str) -> None:
165+
output_path = Path(path)
166+
output_path.parent.mkdir(parents=True, exist_ok=True)
167+
output_path.write_text(content, encoding="utf-8")
168+
169+
170+
def build_arg_parser() -> argparse.ArgumentParser:
171+
parser = argparse.ArgumentParser(description="Build a monthly review artifact from advisory report JSON files.")
172+
parser.add_argument("--current-report", required=True, help="Current advisory report JSON path.")
173+
parser.add_argument("--previous-report", help="Optional previous advisory report JSON path.")
174+
parser.add_argument("--output-json", required=True, help="Output monthly review JSON path.")
175+
parser.add_argument("--output-md", required=True, help="Output monthly review Markdown path.")
176+
return parser
177+
178+
179+
def main(argv: list[str] | None = None) -> None:
180+
args = build_arg_parser().parse_args(argv)
181+
current = load_report(args.current_report)
182+
previous = load_report(args.previous_report) if args.previous_report else None
183+
review = build_monthly_review(
184+
current_report=current,
185+
previous_report=previous,
186+
current_report_path=args.current_report,
187+
previous_report_path=args.previous_report or "",
188+
)
189+
write_json(args.output_json, review)
190+
write_text(args.output_md, render_monthly_review_markdown(review))
191+
192+
193+
if __name__ == "__main__":
194+
main()

0 commit comments

Comments
 (0)