
Rq Idea Generation
- 1 installs
- 43 repo stars
- Updated June 23, 2026
- ricequant/ricequant-skills
RQ Idea Generation is an agent skill that turns RiceQuant JSON screening snapshots into a multi-strategy equity idea report with value, growth, and quality candidate tables.
About
RQ Idea Generation is a RiceQuant agent skill for quantitative investors who need repeatable equity screening narratives instead of one-off chat opinions. developers and small quant teams use it during early research to turn a defined order_book_id universe into an investment creative report dated [[REPORT_DATE]] with value, growth, and quality candidate tables plus overlap analysis. Scripts materialize idea_screening_snapshot.json from on-disk JSON inputs, then the agent enriches narrative summaries before generate_report.py renders the final Chinese Markdown artifact. It assumes you already curate stock pools and latest financials in the documented schemas, including quarter distribution and sector breakdowns. The workflow suits Claude or Cursor agents wired into a local --data-dir rather than live brokerage execution, and it pairs naturally with downstream backtest or validation skills once candidates are shortlisted.
- Markdown report template with placeholders for executive summary, universe facts, and three strategy sleeves (value, gro
- Data contract across stock_pool.json, instrument_meta.json, and latest_financials.json feeding build_screening_snapshot.
- Two-step pipeline: LLM fills summaries.* in idea_screening_snapshot.json then generate_report.py emits final Markdown
- Overlap and cross-validation section for names hitting multiple screens
- Risk, tracking, and appendix sections with explicit data-coverage facts
Rq Idea Generation by the numbers
- 1 all-time installs (skills.sh)
- Ranked #909 of 1,106 Finance & Trading skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Aug 3, 2026 (Skillselion catalog sync)
npx skills add https://github.com/ricequant/ricequant-skills --skill rq-idea-generationAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 43 |
| Security audit | 2 / 3 scanners passed |
| Last updated | June 23, 2026 |
| Repository | ricequant/ricequant-skills ↗ |
What it does
Generate a structured China A-share investment idea report from RiceQuant screening snapshots across value, growth, and quality lenses.
Who is it for?
Best when you use RiceQuant-style data folders and want templated idea memos in Chinese with explicit data contracts.
Skip if: Skip if you need live trading execution, US-only equities without order_book_id conventions, or fully automated picks without human summary review.
When should I use this skill?
You have --data-dir JSON inputs (stock pool, instrument meta, latest financials) and need a dated investment creative Markdown report.
What you get
After the pipeline runs, you get a filled idea_screening_snapshot.json and a final Markdown investment creative report ready for committee review or deeper backtesting.
- idea_screening_snapshot.json with populated summaries
- Final Chinese Markdown investment idea report
By the numbers
- Three parallel strategy sections: value, growth, and quality with separate fact blocks and candidate tables
- Default pipeline: build_screening_snapshot.py then generate_report.py after LLM fills summaries.*
Files
RQ 股票研究 - 投资创意生成
核心原则
- 所有内容必须遵循三阶段流程:数据采集 -> LLM 生成正文 -> HTML 渲染
assets/template.md是唯一报告模板来源;Python 只做数据归一化、指标计算、结构化快照生成、占位符填充和结构校验- Python 只输出结构化 facts / tables / thresholds,不在代码里硬写推荐语、主题判断或客户可读结论
- 报告必须严格使用真实
rqdata原始文件,不得在代码里硬写候选名单、日期、行业结论或投资判断 - 候选名单只代表“进入下一轮研究池的线索”,不代表最终投资结论
- 缺少数据时必须明确写“无数据 / 未启用该策略 / 未验证”,不能留空
数据源分工
RQData CLI 负责
- 股票池、公司名称、行业归属
- 财务快照、历史财务、同比增速
- ROE、市值、PE、PB 等定量筛选指标
- 候选排序、覆盖率、行业分布、交叉命中等结构化事实
web_search 负责
- 量化结果出来后的主题验证
- 行业景气、政策变化、监管动态
- 候选池相关的最新新闻、会议、产业催化
web_search 禁止替代的内容
- 财务数字、估值指标、股票池和公司基础信息
- 正式公告、价格、成交额、一致预期等结构化金融数据
- 任何候选排序、阈值判断和基础量化筛选结果
web_search 使用规则
详细字段、来源等级、落盘示例和 fallback 规则见 references/web_search.md。
允许补充的内容:
- 某条主题线索是否存在最新政策或产业催化
- 候选集中行业的景气验证和关键争议点
- 候选公司是否存在影响研究优先级的最新公开事件
落盘要求:
- 所有
web_search结果必须先写入web_search_findings.json - 只写结构化记录,不把自然语言草稿直接塞进报告
generate_report.py不直接消费该文件;当前 LLM 应在回写idea_screening_snapshot.json -> summaries.*前读取它- 若未做
web_search,报告必须保持“量化候选池”定位,不能伪装成已完成主题验证
硬性规则
以下任一条违反,视为输出失败:
[MUST-1]所有定量筛选指标必须来自RQData CLI[MUST-2]web_search只用于第二阶段主题验证,不能替代量化主数据[MUST-3]金额类数据在正文和表格中必须换算为“亿元”等客户可读口径[MUST-4]候选池不是最终结论;正文必须写清筛选逻辑、催化剂线索和关键风险,不能把候选直接写成“推荐买入”[MUST-5]idea_screening_snapshot.json -> summaries.*必须由当前 LLM 直接回写;Python 不负责代写正文[MUST-6]每个关键数据点或关键结论都要标数据来源:XXX,置信度X[MUST-7]若某策略未启用或未形成候选,仍要由 LLM 明确写清该章节当前用途和缺口,不能留空[MUST-8]低置信度外部信息不得改写量化筛选结论或候选优先级[MUST-9]最终输出必须严格来自模板,不得在脚本中自由拼写整篇报告
确信度评级
5:RQData CLI、交易所公告、上市公司官网、官方监管披露4:政府 / 监管 / 行业协会 / 官方机构、权威财经媒体3:一般媒体或二手整理,但来源清晰且与其他来源一致2:单一来源、细节不完整、时点未充分验证1:推断、估算窗口、未验证信息
使用规则:
- 混合结论的置信度取关键来源中的最低值
- 推断、主题映射或媒体传闻不得标成高置信度
- 低置信度信息只能作为跟踪线索,不能直接升级为核心投资判断
图表 / 图片需求
当前脚本以表格交付最小可用版本,但本 skill 仍必须定义达标报告所需的视觉载体;若未生成图表,必须由等价表格降级,不能省略关键问题。
- 图表名称:股票池覆盖与行业分布
- 图表目的:说明股票池规模、最新财报季度覆盖和行业集中情况
- 使用的数据文件:
stock_pool.json、instrument_meta.json、financials_history.json - 关键字段:
order_book_id、quarter、sector_code_name/industry_name - 建议图表类型:柱状图或分布表
- 回答问题:当前筛选结果是否受样本覆盖或行业集中度影响
- 放置位置:
## 股票池与筛选框架 - 若图表缺失:保留覆盖率表、财报季度分布表、行业分布表
- 图表名称:指标看板与候选对比
- 图表目的:比较全池中位数与各策略候选的关键指标
- 使用的数据文件:
idea_screening_snapshot.json - 关键字段:
universe.median_metrics、strategies.*.median_metrics、strategies.*.candidate_count - 建议图表类型:分组柱状图、雷达图或对比表
- 回答问题:价值 / 成长 / 质量三组候选的主要特征差异是什么
- 放置位置:
## 指标看板 - 若图表缺失:保留指标看板说明与候选表格
- 图表名称:交叉命中与风险暴露
- 图表目的:展示多策略共振程度与候选集中风险
- 使用的数据文件:
idea_screening_snapshot.json - 关键字段:
overlap.display_candidates、risk_flags.* - 建议图表类型:交叉矩阵、热力表或风险摘要表
- 回答问题:哪些候选值得优先进入下一轮研究,当前结果的主要结构性风险是什么
- 放置位置:
## 候选组合与交叉验证、## 风险与跟踪重点 - 若图表缺失:保留交叉命中表和风险事实表
目标产出
- 报告长度:8-12 页
- 正文目标:约
3000-5000中文字符 - 输出文件:
- Markdown 报告
- HTML 报告(若本地已安装渲染器)
- 输出目录必须由
--data-dir/--output指定,不能写死固定路径
目录结构
idea-generation/
├── SKILL.md
├── scripts/
│ ├── build_screening_snapshot.py
│ └── generate_report.py
├── assets/
│ └── template.md
└── references/
├── data_contract.md
└── web_search.md输入文件契约
原始数据目录由 --data-dir 指定,脚本会按下列文件名查找输入:
stock_pool.jsoninstrument_meta.jsonlatest_financials.jsonfinancials_history.jsonroe.jsonmarket_cap.jsonpe_ratio.jsonpb_ratio.jsonweb_search_findings.json(可选,仅供 LLM 回写 summary 前读取)
这些文件都允许以下结构:
{ "data": [...] }{ "data": { ... } }[...]{ ... }
其中:
stock_pool.json用于定义股票池instrument_meta.json用于公司名称和行业字段补充latest_financials.json用于读取每只股票最近一期财务快照financials_history.json用于计算同比增长roe.json、market_cap.json、pe_ratio.json、pb_ratio.json用于估值与质量筛选web_search_findings.json只作为第二阶段主题验证输入,不参与 Python 打分或排序
完整字段说明见 references/data_contract.md。
工作流
步骤 1:准备参数
REPORT_DATE="${REPORT_DATE:-$(date +%F)}"
TOP_N="${TOP_N:-10}"
STRATEGY="${STRATEGY:-all}"
START_QUARTER="$(python3 - <<PY
from datetime import date
report_date = date.fromisoformat("${REPORT_DATE}")
print(f"{report_date.year - 2}q1")
PY
)"
END_QUARTER="$(python3 - <<PY
from datetime import date
report_date = date.fromisoformat("${REPORT_DATE}")
print(f"{report_date.year}q4")
PY
)"
DATA_DIR="${DATA_DIR:-$HOME/rq_equities_reports/idea_generation}"
SNAPSHOT_JSON="${SNAPSHOT_JSON:-$DATA_DIR/idea_screening_snapshot.json}"
OUTPUT_MD="${OUTPUT_MD:-$DATA_DIR/idea_generation_${STRATEGY}_${REPORT_DATE}.md}"步骤 2:采集全市场股票池
mkdir -p "$DATA_DIR"
rqdata stock cn list --payload "{
\"date\": \"$REPORT_DATE\",
\"type\": \"CS\"
}" --format json > "$DATA_DIR/stock_pool.json"步骤 3:分批采集基础信息、财务和估值指标
python3 - "$DATA_DIR" "$REPORT_DATE" "$START_QUARTER" "$END_QUARTER" <<'PY'
import json
import subprocess
import sys
from pathlib import Path
data_dir = Path(sys.argv[1])
report_date = sys.argv[2]
start_quarter = sys.argv[3]
end_quarter = sys.argv[4]
stock_pool = json.loads(data_dir.joinpath("stock_pool.json").read_text(encoding="utf-8"))
rows = stock_pool if isinstance(stock_pool, list) else stock_pool.get("data", [])
ids = [row["order_book_id"] for row in rows if isinstance(row, dict) and row.get("order_book_id")]
def fetch_batches(command_prefix, payload_builder, output_file):
collected = []
for start in range(0, len(ids), 800):
chunk = ids[start:start + 800]
payload = json.dumps(payload_builder(chunk), ensure_ascii=False)
output = subprocess.check_output([*command_prefix, "--payload", payload, "--format", "json"], text=True)
batch = json.loads(output)
collected.extend(batch if isinstance(batch, list) else batch.get("data", []))
data_dir.joinpath(output_file).write_text(json.dumps(collected, ensure_ascii=False, indent=2), encoding="utf-8")
fetch_batches(
["rqdata", "stock", "cn", "instruments"],
lambda chunk: {"order_book_ids": chunk},
"instrument_meta.json",
)
fetch_batches(
["rqdata", "stock", "cn", "financial"],
lambda chunk: {
"order_book_ids": chunk,
"fields": ["revenue", "net_profit", "total_assets", "total_liabilities"],
"start_quarter": start_quarter,
"end_quarter": end_quarter,
"statements": "all",
},
"financials_history.json",
)
data_dir.joinpath("latest_financials.json").write_text(
data_dir.joinpath("financials_history.json").read_text(encoding="utf-8"),
encoding="utf-8",
)
for factor, output_file in [
("return_on_equity_weighted_average", "roe.json"),
("market_cap", "market_cap.json"),
("pe_ratio", "pe_ratio.json"),
("pb_ratio", "pb_ratio.json"),
]:
fetch_batches(
["rqdata", "stock", "cn", "financial-indicator"],
lambda chunk, current_factor=factor: {
"order_book_ids": chunk,
"factor": current_factor,
"start_date": report_date,
"end_date": report_date,
},
output_file,
)
PY说明:
idea-generation默认以全市场 A 股作为筛选覆盖范围,不再优先局限于单一指数成分股financial-indicator使用factor参数,不是fieldsfinancials_history.json与latest_financials.json可以复用同一份查询结果,脚本会自动按股票抽取最新季度并计算同比- 全市场数据量较大,必须按批次抓取,不能把全部
order_book_id一次性塞给单个命令
步骤 4:生成结构化筛选快照
python3 idea-generation/scripts/build_screening_snapshot.py \
--strategy "$STRATEGY" \
--data-dir "$DATA_DIR" \
--report-date "$REPORT_DATE" \
--top-n "$TOP_N" \
--value-pe-median-multiplier "${VALUE_PE_MEDIAN_MULTIPLIER:-1.00}" \
--value-pb-max "${VALUE_PB_MAX:-1.50}" \
--value-roe-min "${VALUE_ROE_MIN:-10.0}" \
--growth-revenue-yoy-min "${GROWTH_REVENUE_YOY_MIN:-15.0}" \
--growth-profit-yoy-min "${GROWTH_PROFIT_YOY_MIN:-20.0}" \
--growth-roe-min "${GROWTH_ROE_MIN:-15.0}" \
--quality-roe-min "${QUALITY_ROE_MIN:-15.0}" \
--quality-debt-ratio-max "${QUALITY_DEBT_RATIO_MAX:-50.0}" \
--output "$SNAPSHOT_JSON"步骤 4.5:可选的主题 / 催化剂验证
当用户明确要求主题扫描,或量化结果需要实时外部语境时,才执行这一步。
- 先用
web_search收集主题、行业、政策或候选公司最新事件 - 结果写入
web_search_findings.json - 不得把
web_search结果直接喂给 Python 做打分、排序或阈值判断 - 若没有执行这一步,最终报告应保持“量化候选池 + 后续验证建议”的口径
步骤 5:当前 LLM 直接回写 summary
运行完快照脚本后,当前 LLM 必须直接读取 "$SNAPSHOT_JSON",并在存在时一并读取 "$DATA_DIR/web_search_findings.json",基于结构化数据和 template.md 回写:
summaries.exec_summarysummaries.universe_overviewsummaries.metric_scoreboardsummaries.value_sectionsummaries.growth_sectionsummaries.quality_sectionsummaries.overlap_sectionsummaries.risk_sectionsummaries.appendix
要求:
- 这些 summary 必须是客户可读正文,不得写“LLM 总结”“脚本生成”“流程说明”
- 文字结论只允许由当前 LLM 基于快照事实和可选
web_search_findings.json生成 - 未启用策略也要写清楚“本轮未启用,但保留该视角作为对照 / 后续扩池方向”
- 若只重复表格字段、输出项目符号堆砌或泄露文件名 /
summaries.*/ workflow 术语,视为未完成
步骤 6:渲染最终 Markdown 报告
常用参数:
--data-dir:原始 JSON 数据目录--snapshot:显式指定结构化快照路径;默认读取--data-dir/idea_screening_snapshot.json--output:输出 Markdown 路径--template:自定义模板路径,默认idea-generation/assets/template.md--no-render:不生成 HTML- 快照阶段阈值参数全部显式传入,例如
--growth-revenue-yoy-min、--quality-roe-min;这些阈值由用户决定,脚本只负责执行
步骤 7:渲染 HTML
脚本会优先尝试调用本地安装的 rq-report-renderer,若未安装则回退到仓库内 report-renderer/scripts/render_report.py;仍不可用时保留 Markdown 并打印警告。
阶段门控
Gate 1:数据采集完成
- 原始 JSON 文件齐全
- 股票池、财务、ROE、PE、PB 都有真实记录
- 没有用固定股票名单或手填候选替代数据采集
Gate 2:结构化快照完成
idea_screening_snapshot.json已生成strategies.*.thresholds记录了本次真实参数summaries.*仍为空,说明 Python 没有越界代写正文
Gate 3:LLM 正文完成
summaries.*已全部回写- 正文是客户可读内容,不是字段复读或内部流程描述
- 若使用
web_search,其信息只体现在总结与跟踪建议,不改写量化主结论
Gate 4:交付完成
- Markdown 已生成
- 若本地渲染器可用,HTML 已生成
- 报告长度、章节、来源标注和风险提示均达标
模板规则
- 报告必须严格基于 template.md 生成
- 占位符采用
[[TOKEN]]语法,不使用 Jinja - 当前模板仅允许以下占位符:
[[REPORT_DATE]][[STRATEGY]][[EXEC_SUMMARY]][[UNIVERSE_OVERVIEW]][[UNIVERSE_FACTS]][[QUARTER_TABLE]][[SECTOR_TABLE]][[METRIC_SCOREBOARD]][[METRIC_TABLE]][[VALUE_SUMMARY]][[VALUE_FACTS]][[VALUE_TABLE]][[GROWTH_SUMMARY]][[GROWTH_FACTS]][[GROWTH_TABLE]][[QUALITY_SUMMARY]][[QUALITY_FACTS]][[QUALITY_TABLE]][[OVERLAP_SUMMARY]][[OVERLAP_FACTS]][[OVERLAP_TABLE]][[RISK_SUMMARY]][[RISK_FACTS]][[APPENDIX_SUMMARY]][[APPENDIX_FACTS]]
报告质量要求
- 完整包含模板中的主章节
- 候选股票必须来自真实股票池与真实财务 / 估值指标
- 不得残留
[XX]、[需要补充]、[股票A]这类占位文本 - 文本必须说明筛选规则、覆盖范围、候选意义和关键风险,不能只有空表格
- 每个策略都要说明“启用 / 未启用”和结果数量
- 不能把量化候选直接写成确定性投资结论,必须保留“候选池 / 下一轮验证”边界
- 建议正文达到
3000-5000中文字符;单策略模式也应保留足够的筛选框架、预览和跟踪说明,不能退化成简表
阶段验收清单
- [ ]
idea_screening_snapshot.json成功生成,且summaries.*完整回写 - [ ] Markdown 文件存在
- [ ] 若本地渲染器存在则 HTML 文件存在
- [ ] 模板占位符无残留
- [ ] 主章节完整
- [ ] 报告长度达到 8-12 页的最低可交付标准
- [ ] 关键数据点与关键结论带
数据来源:XXX,置信度X - [ ] 至少一个启用策略生成非空候选表
- [ ] 未启用策略章节仍有合格说明,不是空白
- [ ] 若使用
web_search,相关记录已落盘且未越权替代 RQData 主数据
常见错误
- 把
financial-indicator返回字段错误地当成统一value - 直接在 Python 里硬写“推荐股票名单”或评论句,而不是让当前 LLM 基于快照生成正文
- 使用固定日期、固定季度、固定输出路径
- 只输出表格,不解释规则、覆盖率和风险
- 把候选池直接写成确定性结论,忽略第二阶段验证
- 继续依赖旧版
~/.claude/skills/...或仓库级utils
投资创意生成报告
- 报告日期:[[REPORT_DATE]]
- 筛选策略:
[[STRATEGY]]
执行摘要
[[EXEC_SUMMARY]]
股票池与筛选框架
[[UNIVERSE_OVERVIEW]]
股票池覆盖
[[UNIVERSE_FACTS]]
最新财报季度分布
[[QUARTER_TABLE]]
行业分布
[[SECTOR_TABLE]]
指标看板
[[METRIC_SCOREBOARD]] [[METRIC_TABLE]]
价值策略候选
[[VALUE_SUMMARY]]
结构化事实
[[VALUE_FACTS]]
候选列表
[[VALUE_TABLE]]
成长策略候选
[[GROWTH_SUMMARY]]
结构化事实
[[GROWTH_FACTS]]
候选列表
[[GROWTH_TABLE]]
质量策略候选
[[QUALITY_SUMMARY]]
结构化事实
[[QUALITY_FACTS]]
候选列表
[[QUALITY_TABLE]]
候选组合与交叉验证
[[OVERLAP_SUMMARY]]
结构化事实
[[OVERLAP_FACTS]]
交叉命中名单
[[OVERLAP_TABLE]]
风险与跟踪重点
[[RISK_SUMMARY]]
结构化事实
[[RISK_FACTS]]
附录:口径说明
[[APPENDIX_SUMMARY]]
输入数据覆盖
[[APPENDIX_FACTS]]
idea-generation 数据契约
idea-generation/scripts/build_screening_snapshot.py 默认从 --data-dir 读取原始 JSON 文件,并生成 idea_screening_snapshot.json。当前 LLM 需先回写其中 summaries.*,再由 idea-generation/scripts/generate_report.py 生成最终 Markdown。
1. stock_pool.json
允许格式:
[
{ "order_book_id": "600519.XSHG" }
]或:
{
"data": [
{ "order_book_id": "600519.XSHG" }
]
}最关键字段:
order_book_id
用途:
- 定义筛选股票池
2. instrument_meta.json
典型字段:
order_book_idsymboldisplay_namesector_code_nameindustry_name
用途:
- 补充公司名称和行业 / 板块信息
3. latest_financials.json
典型字段:
order_book_idquarterinfo_daterevenuenet_profittotal_assetstotal_liabilities
用途:
- 为每只股票抽取最近一期财务快照
- 计算资产负债率
4. financials_history.json
字段与 latest_financials.json 一致。
用途:
- 查找同季度去年同期数据
- 计算营收同比、净利润同比
5. roe.json
典型字段:
order_book_iddatereturn_on_equity_weighted_average
用途:
- 质量与价值筛选
6. market_cap.json
典型字段:
order_book_iddatemarket_cap
用途:
- 候选排序与规模描述
7. pe_ratio.json
典型字段:
order_book_iddatepe_ratio
用途:
- 价值筛选
8. pb_ratio.json
典型字段:
order_book_iddatepb_ratio
用途:
- 价值筛选
9. web_search_findings.json
该文件可选,仅用于量化筛选后的主题 / 政策 / 催化剂验证。
每条记录至少包含:
querysource_namesource_typetitleurlpublished_atretrieved_atsummarywhy_relevantconfidencefinding_type
推荐附加字段:
subjectrelated_tickersstance
说明:
generate_report.py不直接读取该文件;当前 LLM 应在回写idea_screening_snapshot.json -> summaries.*前读取它web_search_findings.json不能替代财务、估值、股票池和排序主数据source_type/confidence需遵守 references/web_search.md 的来源等级约束
10. idea_screening_snapshot.json
这是 build_screening_snapshot.py 生成、并由当前 LLM 继续回写 summary 的核心中间文件。
关键结构:
report_datestrategyinputs.*universe.stock_pool_sizeuniverse.valid_financialsuniverse.valid_roeuniverse.valid_peuniverse.valid_pbuniverse.latest_quarters[]universe.top_sectors[]universe.median_metrics.*strategies.value / growth / qualitystrategies.*.enabledstrategies.*.candidate_countstrategies.*.display_countstrategies.*.thresholds.*strategies.*.display_candidates[]strategies.*.median_metrics.*overlap.candidate_countoverlap.display_candidates[]risk_flags.*summaries.exec_summarysummaries.universe_overviewsummaries.metric_scoreboardsummaries.value_sectionsummaries.growth_sectionsummaries.quality_sectionsummaries.overlap_sectionsummaries.risk_sectionsummaries.appendix
说明:
summaries.*初始应为空字符串,表示 Python 没有越界生成正文summaries.*必须由当前 LLM 回写客户可读正文,不能只复制表格字段或写流程描述- 若某策略未启用,相应 summary 也必须解释该章节的对照价值或后续用途
strategies.*.thresholds记录了本次实际使用的用户阈值,最终报告必须按这些阈值解释结果,不能自行改口径
解析约定
- 所有文件都允许
{"data": [...]}、{"data": {...}}、[...]、{...}四种包装方式 financial-indicator文件中的值字段不是统一value,而是因子名本身- 同一股票同一季度若存在多条财务记录,脚本会按
info_date选择最新披露版本 - 缺失值不会抛异常,但报告中必须明确体现为“无数据 / 未启用 / 未验证”
- 金额类字段最终报告中应按客户可读口径展示,例如市值与净利润使用“亿元”
- 最终报告面向客户阅读:正文由当前 LLM 基于快照事实和可选
web_search_findings.json生成,Python 只负责结构化数据与表格渲染
Idea Generation Web Search Reference
Purpose
Use web_search only to supplement real-time information that RQData CLI does not directly provide for theme validation after the quantitative screen is finished.
Allowed Coverage
- Theme validation for sectors or concepts highlighted by the screen
- Policy or regulatory changes relevant to candidate industries
- Important recent company news or conference signals that may affect research priority
- External catalysts that help explain why a candidate may deserve the next round of work
Prohibited Usage
- Do not replace financial statements, valuation multiples, stock pool construction, or candidate ranking
- Do not use
web_searchto fabricate official disclosures or structured financial data - Do not promote low-confidence media snippets into final investment conclusions
Required Output File
All external findings must be written to web_search_findings.json.
Each record must contain:
querysource_namesource_typetitleurlpublished_atretrieved_atsummarywhy_relevantconfidencefinding_type
Recommended fields:
subjectrelated_tickersstance
Allowed finding_type
theme_validationindustry_contextpolicy_contextcompany_newscatalyst
Source Types And Confidence Ceiling
official: max confidence5government: max confidence4association: max confidence4authoritative_media: max confidence4general_news: max confidence3inference: max confidence1
Search Workflow
1. Confirm the needed information is not directly available from RQData CLI. 2. Prefer official and primary sources first. 3. Save the finding into web_search_findings.json with structured metadata. 4. Keep the summary factual and keep the relevance note concrete. 5. Use the findings only to validate or prioritize themes, not to rewrite quantitative facts.
Fallback
1. Use the native web_search tool when available. 2. Otherwise use the configured network search tool in the current environment. 3. If neither is available:
- do not fabricate real-time information
- explicitly mark that theme validation is unavailable or unverified
- keep the report at the quantitative-candidate level instead of pretending the theme is confirmed
Example
{
"data": [
{
"query": "人形机器人 政策 2026 4月",
"source_name": "工业和信息化部",
"source_type": "government",
"title": "关于推动智能制造场景建设的通知",
"url": "https://www.example.com/policy",
"published_at": "2026-04-02",
"retrieved_at": "2026-04-07",
"summary": "文件强调高端装备和智能制造场景建设,强化产业链设备投资预期。",
"why_relevant": "可用于验证量化筛选中高端装备候选的景气主线是否仍在强化。",
"confidence": 4,
"finding_type": "policy_context",
"subject": "智能制造政策",
"related_tickers": ["300124.XSHE", "688777.XSHG"],
"stance": "positive"
}
]
}#!/usr/bin/env python3
"""Build structured screening snapshot for idea-generation."""
from __future__ import annotations
import argparse
import json
import re
from collections import Counter
from datetime import date, datetime
from pathlib import Path
from statistics import median
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
TICKER_FIELDS = ("order_book_id", "ticker", "stock_code", "symbol", "code")
NAME_FIELDS = ("display_name", "name", "stock_name", "company_name", "symbol_name", "symbol")
SECTOR_FIELDS = ("sector_code_name", "industry_name", "sector_name")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="生成 idea-generation 结构化筛选快照")
parser.add_argument("--strategy", required=True, choices=["value", "growth", "quality", "all"], help="筛选策略")
parser.add_argument("--data-dir", required=True, help="原始 JSON 数据目录")
parser.add_argument("--report-date", default=date.today().isoformat(), help="报告日期 (YYYY-MM-DD)")
parser.add_argument("--output", help="输出结构化快照 JSON 路径")
parser.add_argument("--top-n", type=int, default=10, help="每个策略展示的候选数量")
parser.add_argument("--value-pe-median-multiplier", type=float, default=1.0, help="价值策略 PE 上限相对全池 PE 中位数的倍数")
parser.add_argument("--value-pb-max", type=float, default=1.5, help="价值策略 PB 上限")
parser.add_argument("--value-roe-min", type=float, default=10.0, help="价值策略 ROE 下限")
parser.add_argument("--growth-revenue-yoy-min", type=float, default=15.0, help="成长策略营收同比下限")
parser.add_argument("--growth-profit-yoy-min", type=float, default=20.0, help="成长策略净利润同比下限")
parser.add_argument("--growth-roe-min", type=float, default=15.0, help="成长策略 ROE 下限")
parser.add_argument("--quality-roe-min", type=float, default=15.0, help="质量策略 ROE 下限")
parser.add_argument("--quality-debt-ratio-max", type=float, default=50.0, help="质量策略资产负债率上限")
return parser.parse_args()
def parse_iso_date(value: Any) -> Optional[date]:
if value in (None, "", "null"):
return None
if isinstance(value, date) and not isinstance(value, datetime):
return value
if isinstance(value, datetime):
return value.date()
text = str(value).strip()
if not text:
return None
for fmt in (
"%Y-%m-%d",
"%Y/%m/%d",
"%Y-%m-%d %H:%M:%S",
"%Y/%m/%d %H:%M:%S",
"%Y-%m-%dT%H:%M:%S",
"%Y-%m-%dT%H:%M:%S.%f",
):
try:
return datetime.strptime(text, fmt).date()
except ValueError:
continue
try:
return datetime.strptime(text[:10], "%Y-%m-%d").date()
except ValueError:
return None
def parse_quarter_key(value: str) -> Optional[Tuple[int, int]]:
match = re.match(r"^(\d{4})q([1-4])$", str(value).strip().lower())
if not match:
return None
return int(match.group(1)), int(match.group(2))
def read_json_file(path: Path) -> Any:
if not path.exists():
return None
with path.open("r", encoding="utf-8") as fh:
return json.load(fh)
def extract_records(payload: Any) -> List[Any]:
if payload is None:
return []
if isinstance(payload, list):
return payload
if isinstance(payload, dict):
if "data" in payload:
data = payload["data"]
if isinstance(data, list):
return data
if isinstance(data, dict):
return [data]
return []
return [payload]
return []
def pick_first(record: Dict[str, Any], fields: Sequence[str]) -> Any:
for field in fields:
if field in record and record[field] not in (None, ""):
return record[field]
return None
def normalize_ticker(record: Dict[str, Any]) -> str:
value = pick_first(record, TICKER_FIELDS)
return str(value).strip() if value not in (None, "") else ""
def normalize_name(record: Dict[str, Any]) -> str:
value = pick_first(record, NAME_FIELDS)
return str(value).strip() if value not in (None, "") else ""
def normalize_sector(record: Dict[str, Any]) -> str:
value = pick_first(record, SECTOR_FIELDS)
return str(value).strip() if value not in (None, "") else "未分类"
def float_or_none(value: Any) -> Optional[float]:
if value in (None, "", "null"):
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def number_text(value: Optional[float], digits: int = 1) -> str:
if value is None:
return "无数据"
return f"{value:.{digits}f}"
def percent_text(value: Optional[float], digits: int = 1) -> str:
if value is None:
return "无数据"
return f"{value:+.{digits}f}%"
def unsigned_percent_text(value: Optional[float], digits: int = 1) -> str:
if value is None:
return "无数据"
return f"{value:.{digits}f}%"
def billion_yuan_text(value: Optional[float]) -> str:
if value is None:
return "无数据"
return f"{value / 1e8:.2f}亿元"
def x_text(value: Optional[float], digits: int = 2) -> str:
if value is None:
return "无数据"
return f"{value:.{digits}f}x"
def median_or_none(values: Iterable[Optional[float]]) -> Optional[float]:
valid = [value for value in values if value is not None]
if not valid:
return None
return float(median(valid))
def build_stock_pool(records: List[Any]) -> List[str]:
order_book_ids: List[str] = []
seen = set()
for item in records:
if not isinstance(item, dict):
continue
order_book_id = normalize_ticker(item)
if not order_book_id or order_book_id in seen:
continue
seen.add(order_book_id)
order_book_ids.append(order_book_id)
return order_book_ids
def build_instrument_map(records: List[Any], stock_ids: Sequence[str]) -> Dict[str, Dict[str, str]]:
stock_set = set(stock_ids)
result: Dict[str, Dict[str, str]] = {}
for item in records:
if not isinstance(item, dict):
continue
stock = normalize_ticker(item)
if stock not in stock_set:
continue
result[stock] = {
"name": normalize_name(item) or stock,
"sector": normalize_sector(item),
}
return result
def dedupe_financial_records(records: List[Any], stock_ids: Sequence[str]) -> List[Dict[str, Any]]:
stock_set = set(stock_ids)
deduped: Dict[Tuple[str, str], Dict[str, Any]] = {}
for item in records:
if not isinstance(item, dict):
continue
stock = normalize_ticker(item)
quarter = str(item.get("quarter") or "").lower()
if stock not in stock_set or not parse_quarter_key(quarter):
continue
key = (stock, quarter)
current = deduped.get(key)
current_date = parse_iso_date(current.get("info_date")) if current else None
item_date = parse_iso_date(item.get("info_date"))
if current is None or (item_date and (current_date is None or item_date >= current_date)):
deduped[key] = item
return list(deduped.values())
def build_financial_snapshot(records: List[Any], stock_ids: Sequence[str]) -> Dict[str, Dict[str, Any]]:
stock_set = set(stock_ids)
deduped = dedupe_financial_records(records, stock_ids)
grouped: Dict[str, List[Dict[str, Any]]] = {stock: [] for stock in stock_set}
for item in deduped:
stock = normalize_ticker(item)
if stock in grouped:
grouped[stock].append(item)
snapshots: Dict[str, Dict[str, Any]] = {}
for stock, items in grouped.items():
if not items:
continue
items.sort(
key=lambda item: (
parse_quarter_key(str(item.get("quarter") or "").lower()) or (0, 0),
parse_iso_date(item.get("info_date")) or date.min,
)
)
latest = items[-1]
latest_quarter = str(latest.get("quarter") or "").lower()
latest_key = parse_quarter_key(latest_quarter)
yoy = None
if latest_key:
yoy_quarter = f"{latest_key[0] - 1}q{latest_key[1]}"
for item in items:
if str(item.get("quarter") or "").lower() == yoy_quarter:
yoy = item
snapshots[stock] = {"latest": latest, "yoy": yoy}
return snapshots
def latest_factor_map(records: List[Any], stock_ids: Sequence[str], field_name: str) -> Dict[str, float]:
stock_set = set(stock_ids)
best: Dict[str, Tuple[date, float]] = {}
for item in records:
if not isinstance(item, dict):
continue
stock = normalize_ticker(item)
if stock not in stock_set:
continue
event_date = parse_iso_date(item.get("date") or item.get("datetime"))
value = float_or_none(item.get(field_name))
if event_date is None or value is None:
continue
current = best.get(stock)
if current is None or event_date >= current[0]:
best[stock] = (event_date, value)
return {stock: value for stock, (_, value) in best.items()}
def build_universe_rows(
stock_ids: Sequence[str],
instruments: List[Any],
latest_financials: List[Any],
history_financials: List[Any],
roe_records: List[Any],
market_cap_records: List[Any],
pe_records: List[Any],
pb_records: List[Any],
) -> List[Dict[str, Any]]:
instrument_map = build_instrument_map(instruments, stock_ids)
history_snapshot = build_financial_snapshot(history_financials, stock_ids)
latest_snapshot = build_financial_snapshot(latest_financials, stock_ids)
roe_map = latest_factor_map(roe_records, stock_ids, "return_on_equity_weighted_average")
market_cap_map = latest_factor_map(market_cap_records, stock_ids, "market_cap")
pe_map = latest_factor_map(pe_records, stock_ids, "pe_ratio")
pb_map = latest_factor_map(pb_records, stock_ids, "pb_ratio")
rows: List[Dict[str, Any]] = []
for stock in stock_ids:
meta = instrument_map.get(stock, {})
snapshot = latest_snapshot.get(stock) or history_snapshot.get(stock) or {}
latest = snapshot.get("latest") or {}
yoy = snapshot.get("yoy") or (history_snapshot.get(stock) or {}).get("yoy") or {}
revenue = float_or_none(latest.get("revenue"))
net_profit = float_or_none(latest.get("net_profit"))
total_assets = float_or_none(latest.get("total_assets"))
total_liabilities = float_or_none(latest.get("total_liabilities"))
yoy_revenue = float_or_none(yoy.get("revenue"))
yoy_profit = float_or_none(yoy.get("net_profit"))
revenue_yoy = ((revenue / yoy_revenue - 1.0) * 100.0) if revenue is not None and yoy_revenue not in (None, 0) else None
profit_yoy = ((net_profit / yoy_profit - 1.0) * 100.0) if net_profit is not None and yoy_profit not in (None, 0) else None
debt_ratio = ((total_liabilities / total_assets) * 100.0) if total_liabilities is not None and total_assets not in (None, 0) else None
rows.append(
{
"order_book_id": stock,
"name": meta.get("name") or stock,
"sector": meta.get("sector") or "未分类",
"latest_quarter": str(latest.get("quarter") or "无数据"),
"revenue": revenue,
"net_profit": net_profit,
"total_assets": total_assets,
"total_liabilities": total_liabilities,
"revenue_yoy": revenue_yoy,
"profit_yoy": profit_yoy,
"debt_ratio": debt_ratio,
"roe": roe_map.get(stock),
"market_cap": market_cap_map.get(stock),
"pe": pe_map.get(stock),
"pb": pb_map.get(stock),
}
)
return rows
def universe_statistics(rows: Sequence[Dict[str, Any]]) -> Dict[str, Any]:
latest_quarters = Counter(row["latest_quarter"] for row in rows if row.get("latest_quarter") and row["latest_quarter"] != "无数据")
sectors = Counter(row["sector"] for row in rows if row.get("sector"))
valid_financials = sum(1 for row in rows if row.get("revenue") is not None and row.get("net_profit") is not None)
valid_roe = sum(1 for row in rows if row.get("roe") is not None)
valid_pe = sum(1 for row in rows if row.get("pe") is not None)
valid_pb = sum(1 for row in rows if row.get("pb") is not None)
return {
"universe_size": len(rows),
"valid_financials": valid_financials,
"valid_roe": valid_roe,
"valid_pe": valid_pe,
"valid_pb": valid_pb,
"latest_quarters": latest_quarters,
"top_sectors": sectors.most_common(5),
"pe_median": median_or_none(row.get("pe") for row in rows),
"pb_median": median_or_none(row.get("pb") for row in rows),
"roe_median": median_or_none(row.get("roe") for row in rows),
"revenue_yoy_median": median_or_none(row.get("revenue_yoy") for row in rows),
"profit_yoy_median": median_or_none(row.get("profit_yoy") for row in rows),
"market_cap_median": median_or_none(row.get("market_cap") for row in rows),
}
def score_value(row: Dict[str, Any], pe_median: Optional[float]) -> float:
score = 0.0
pe = row.get("pe")
pb = row.get("pb")
roe = row.get("roe")
if pe_median and pe not in (None, 0):
score += max(pe_median - pe, 0.0) / pe_median * 45.0
if pb is not None:
score += max(1.5 - pb, 0.0) / 1.5 * 25.0
if roe is not None:
score += min(roe, 30.0) / 30.0 * 30.0
return score
def score_growth(row: Dict[str, Any]) -> float:
revenue_yoy = row.get("revenue_yoy") or 0.0
profit_yoy = row.get("profit_yoy") or 0.0
roe = row.get("roe") or 0.0
return min(revenue_yoy, 80.0) * 0.3 + min(profit_yoy, 100.0) * 0.5 + min(roe, 30.0) * 0.2
def score_quality(row: Dict[str, Any]) -> float:
roe = row.get("roe") or 0.0
debt_ratio = row.get("debt_ratio")
debt_score = max(50.0 - debt_ratio, 0.0) if debt_ratio is not None else 0.0
return min(roe, 30.0) * 0.6 + debt_score * 0.4
def screen_value(
rows: Sequence[Dict[str, Any]],
top_n: int,
pe_ceiling: Optional[float],
pb_max: float,
roe_min: float,
) -> List[Dict[str, Any]]:
result: List[Dict[str, Any]] = []
for row in rows:
pe = row.get("pe")
pb = row.get("pb")
roe = row.get("roe")
if pe is None or pb is None or roe is None or pe_ceiling is None:
continue
if pe < pe_ceiling and pb < pb_max and roe > roe_min:
candidate = dict(row)
candidate["score"] = score_value(row, pe_ceiling)
result.append(candidate)
result.sort(key=lambda item: (-item["score"], item.get("pe", float("inf"))))
return result[:top_n]
def screen_growth(
rows: Sequence[Dict[str, Any]],
top_n: int,
revenue_yoy_min: float,
profit_yoy_min: float,
roe_min: float,
) -> List[Dict[str, Any]]:
result: List[Dict[str, Any]] = []
for row in rows:
revenue_yoy = row.get("revenue_yoy")
profit_yoy = row.get("profit_yoy")
roe = row.get("roe")
if revenue_yoy is None or profit_yoy is None or roe is None:
continue
if revenue_yoy > revenue_yoy_min and profit_yoy > profit_yoy_min and roe > roe_min:
candidate = dict(row)
candidate["score"] = score_growth(row)
result.append(candidate)
result.sort(key=lambda item: (-item["score"], -(item.get("profit_yoy") or 0.0), -(item.get("revenue_yoy") or 0.0)))
return result[:top_n]
def screen_quality(
rows: Sequence[Dict[str, Any]],
top_n: int,
roe_min: float,
debt_ratio_max: float,
) -> List[Dict[str, Any]]:
result: List[Dict[str, Any]] = []
for row in rows:
roe = row.get("roe")
debt_ratio = row.get("debt_ratio")
if roe is None or debt_ratio is None:
continue
if roe > roe_min and debt_ratio < debt_ratio_max:
candidate = dict(row)
candidate["score"] = score_quality(row)
result.append(candidate)
result.sort(key=lambda item: (-item["score"], item.get("debt_ratio", float("inf"))))
return result[:top_n]
def counter_rows(counter: Counter[str], limit: int = 5) -> List[Dict[str, Any]]:
return [{"name": name, "count": count} for name, count in counter.most_common(limit)]
def metric_dict(rows: Sequence[Dict[str, Any]], fields: Sequence[str]) -> Dict[str, Optional[float]]:
return {field: median_or_none(item.get(field) for item in rows) for field in fields}
def strategy_snapshot(
name: str,
enabled: bool,
all_rows: Sequence[Dict[str, Any]],
display_rows: Sequence[Dict[str, Any]],
thresholds: Dict[str, Any],
) -> Dict[str, Any]:
return {
"enabled": enabled,
"candidate_count": len(all_rows),
"display_count": len(display_rows),
"thresholds": thresholds,
"display_candidates": list(display_rows),
"sector_counts": counter_rows(Counter(item.get("sector") or "未分类" for item in all_rows), 5),
"median_metrics": metric_dict(
all_rows,
("pe", "pb", "roe", "revenue_yoy", "profit_yoy", "debt_ratio", "market_cap", "net_profit"),
),
}
def build_overlap_candidates(
value_rows: Sequence[Dict[str, Any]],
growth_rows: Sequence[Dict[str, Any]],
quality_rows: Sequence[Dict[str, Any]],
) -> List[Dict[str, Any]]:
coverage: Dict[str, Dict[str, Any]] = {}
for strategy, rows in (("value", value_rows), ("growth", growth_rows), ("quality", quality_rows)):
for item in rows:
current = coverage.setdefault(
item["order_book_id"],
{
"order_book_id": item["order_book_id"],
"name": item["name"],
"sector": item["sector"],
"latest_quarter": item["latest_quarter"],
"roe": item.get("roe"),
"pe": item.get("pe"),
"pb": item.get("pb"),
"revenue_yoy": item.get("revenue_yoy"),
"profit_yoy": item.get("profit_yoy"),
"strategies": [],
},
)
current["strategies"].append(strategy)
return sorted(
[item for item in coverage.values() if len(item["strategies"]) >= 2],
key=lambda item: (-len(item["strategies"]), item["name"]),
)
def main() -> None:
args = parse_args()
data_dir = Path(args.data_dir).expanduser()
stock_pool_records = extract_records(read_json_file(data_dir / "stock_pool.json"))
instrument_records = extract_records(read_json_file(data_dir / "instrument_meta.json"))
latest_financials = extract_records(read_json_file(data_dir / "latest_financials.json"))
financials_history = extract_records(read_json_file(data_dir / "financials_history.json"))
roe_records = extract_records(read_json_file(data_dir / "roe.json"))
market_cap_records = extract_records(read_json_file(data_dir / "market_cap.json"))
pe_records = extract_records(read_json_file(data_dir / "pe_ratio.json"))
pb_records = extract_records(read_json_file(data_dir / "pb_ratio.json"))
stock_ids = build_stock_pool(stock_pool_records)
if not stock_ids:
stock_ids = list({normalize_ticker(item) for item in instrument_records if isinstance(item, dict) and normalize_ticker(item)})
if not stock_ids:
raise ValueError("无法从 stock_pool.json 或 instrument_meta.json 识别股票池。")
rows = build_universe_rows(
stock_ids,
instrument_records,
latest_financials,
financials_history,
roe_records,
market_cap_records,
pe_records,
pb_records,
)
stats = universe_statistics(rows)
value_pe_ceiling = (stats.get("pe_median") * args.value_pe_median_multiplier) if stats.get("pe_median") is not None else None
value_thresholds = {
"pe_lt": value_pe_ceiling,
"pe_median_multiplier": args.value_pe_median_multiplier,
"pb_lt": args.value_pb_max,
"roe_gt": args.value_roe_min,
}
growth_thresholds = {
"revenue_yoy_gt": args.growth_revenue_yoy_min,
"profit_yoy_gt": args.growth_profit_yoy_min,
"roe_gt": args.growth_roe_min,
}
quality_thresholds = {
"roe_gt": args.quality_roe_min,
"debt_ratio_lt": args.quality_debt_ratio_max,
}
value_all = screen_value(rows, len(rows), value_pe_ceiling, args.value_pb_max, args.value_roe_min)
growth_all = screen_growth(rows, len(rows), args.growth_revenue_yoy_min, args.growth_profit_yoy_min, args.growth_roe_min)
quality_all = screen_quality(rows, len(rows), args.quality_roe_min, args.quality_debt_ratio_max)
value_candidates = value_all[: args.top_n]
growth_candidates = growth_all[: args.top_n]
quality_candidates = quality_all[: args.top_n]
strategy_enabled = {
"value": args.strategy in ("value", "all"),
"growth": args.strategy in ("growth", "all"),
"quality": args.strategy in ("quality", "all"),
}
overlap_rows = build_overlap_candidates(value_all, growth_all, quality_all)
selected_rows = []
if strategy_enabled["value"]:
selected_rows.extend(value_candidates)
if strategy_enabled["growth"]:
selected_rows.extend(growth_candidates)
if strategy_enabled["quality"]:
selected_rows.extend(quality_candidates)
selected_sector_counts = Counter(item["sector"] for item in selected_rows if item.get("sector"))
top_sector = selected_sector_counts.most_common(1)
snapshot = {
"report_date": args.report_date,
"strategy": args.strategy,
"top_n": args.top_n,
"inputs": {
"stock_pool": len(stock_pool_records),
"instrument_meta": len(instrument_records),
"latest_financials": len(latest_financials),
"financials_history": len(financials_history),
"roe": len(roe_records),
"market_cap": len(market_cap_records),
"pe_ratio": len(pe_records),
"pb_ratio": len(pb_records),
},
"universe": {
"stock_pool_size": len(stock_ids),
"valid_financials": stats["valid_financials"],
"valid_roe": stats["valid_roe"],
"valid_pe": stats["valid_pe"],
"valid_pb": stats["valid_pb"],
"latest_quarters": counter_rows(stats["latest_quarters"], 8),
"top_sectors": [{"name": sector, "count": count} for sector, count in stats["top_sectors"]],
"median_metrics": {
"pe": stats.get("pe_median"),
"pb": stats.get("pb_median"),
"roe": stats.get("roe_median"),
"revenue_yoy": stats.get("revenue_yoy_median"),
"profit_yoy": stats.get("profit_yoy_median"),
"market_cap": stats.get("market_cap_median"),
},
},
"strategies": {
"value": strategy_snapshot("value", strategy_enabled["value"], value_all, value_candidates, value_thresholds),
"growth": strategy_snapshot("growth", strategy_enabled["growth"], growth_all, growth_candidates, growth_thresholds),
"quality": strategy_snapshot("quality", strategy_enabled["quality"], quality_all, quality_candidates, quality_thresholds),
},
"overlap": {
"candidate_count": len(overlap_rows),
"display_candidates": overlap_rows[:8],
"sector_counts": counter_rows(Counter(item.get("sector") or "未分类" for item in overlap_rows), 5),
},
"risk_flags": {
"missing_financial_count": stats["universe_size"] - stats["valid_financials"],
"selected_count": len({item["order_book_id"] for item in selected_rows}),
"top_selected_sector": {"name": top_sector[0][0], "count": top_sector[0][1]} if top_sector else None,
"growth_pe_median": median_or_none(item.get("pe") for item in growth_candidates),
"value_quality_overlap_count": len({item["order_book_id"] for item in value_candidates} & {item["order_book_id"] for item in quality_candidates}),
"value_growth_overlap_count": len({item["order_book_id"] for item in value_candidates} & {item["order_book_id"] for item in growth_candidates}),
},
"summaries": {
"exec_summary": "",
"universe_overview": "",
"metric_scoreboard": "",
"value_section": "",
"growth_section": "",
"quality_section": "",
"overlap_section": "",
"risk_section": "",
"appendix": "",
},
}
output_path = Path(args.output).expanduser() if args.output else data_dir / "idea_screening_snapshot.json"
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(json.dumps(snapshot, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"✅ 结构化筛选快照已生成:{output_path}")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Render idea-generation report from structured snapshot + LLM summaries."""
from __future__ import annotations
import argparse
import json
import re
import shutil
import subprocess
from datetime import date
from pathlib import Path
from typing import Any, Dict, List, Optional, Sequence
TOKEN_RE = re.compile(r"\[\[([A-Z0-9_]+)\]\]")
REQUIRED_HEADINGS = [
"# 投资创意生成报告",
"## 执行摘要",
"## 股票池与筛选框架",
"## 指标看板",
"## 价值策略候选",
"## 成长策略候选",
"## 质量策略候选",
"## 候选组合与交叉验证",
"## 风险与跟踪重点",
"## 附录:口径说明",
]
SUMMARY_KEYS = [
"exec_summary",
"universe_overview",
"metric_scoreboard",
"value_section",
"growth_section",
"quality_section",
"overlap_section",
"risk_section",
"appendix",
]
def parse_args() -> argparse.Namespace:
skill_dir = Path(__file__).resolve().parent.parent
parser = argparse.ArgumentParser(description="根据结构化快照与 LLM 摘要生成 idea-generation 报告")
parser.add_argument("--data-dir", help="原始数据目录;默认从其中读取 idea_screening_snapshot.json")
parser.add_argument("--snapshot", help="结构化快照 JSON 路径")
parser.add_argument("--strategy", help="兼容旧入口参数;当前忽略")
parser.add_argument("--report-date", help="兼容旧入口参数;当前忽略")
parser.add_argument("--top-n", help="兼容旧入口参数;当前忽略")
parser.add_argument("--output", required=True, help="输出 Markdown 文件路径")
parser.add_argument("--template", default=str(skill_dir / "assets" / "template.md"), help="Markdown 模板路径")
parser.add_argument("--no-render", action="store_true", help="不尝试渲染 HTML")
return parser.parse_args()
def read_json_file(path: Path) -> Any:
with path.open("r", encoding="utf-8") as fh:
return json.load(fh)
def number_text(value: Optional[float], digits: int = 1) -> str:
if value in (None, "", "null"):
return "无数据"
return f"{float(value):.{digits}f}"
def percent_text(value: Optional[float], digits: int = 1) -> str:
if value in (None, "", "null"):
return "无数据"
return f"{float(value):+.{digits}f}%"
def unsigned_percent_text(value: Optional[float], digits: int = 1) -> str:
if value in (None, "", "null"):
return "无数据"
return f"{float(value):.{digits}f}%"
def billion_yuan_text(value: Optional[float]) -> str:
if value in (None, "", "null"):
return "无数据"
return f"{float(value) / 1e8:.2f}亿元"
def x_text(value: Optional[float], digits: int = 2) -> str:
if value in (None, "", "null"):
return "无数据"
return f"{float(value):.{digits}f}x"
def format_table(headers: Sequence[str], rows: Sequence[Sequence[str]]) -> str:
lines = ["| " + " | ".join(headers) + " |", "| " + " | ".join(["---"] * len(headers)) + " |"]
for row in rows:
lines.append("| " + " | ".join(str(cell) for cell in row) + " |")
return "\n".join(lines)
def render_counter_table(title_field: str, counters: Sequence[Dict[str, Any]]) -> str:
rows = [[item.get("name", "无数据"), str(item.get("count", 0))] for item in counters]
if not rows:
rows = [["无数据", "-"]]
return format_table([title_field, "数量"], rows)
def thresholds_text(name: str, thresholds: Dict[str, Any]) -> str:
if name == "value":
return (
f"PE < {x_text(thresholds.get('pe_lt'), 1)}"
f"(全池 PE 中位数倍数 {number_text(thresholds.get('pe_median_multiplier'), 2)})"
f";PB < {number_text(thresholds.get('pb_lt'), 2)};ROE > {number_text(thresholds.get('roe_gt'), 1)}%"
)
if name == "growth":
return f"营收同比 > {number_text(thresholds.get('revenue_yoy_gt'), 1)}%;净利润同比 > {number_text(thresholds.get('profit_yoy_gt'), 1)}%;ROE > {number_text(thresholds.get('roe_gt'), 1)}%"
return f"ROE > {number_text(thresholds.get('roe_gt'), 1)}%;资产负债率 < {number_text(thresholds.get('debt_ratio_lt'), 1)}%"
def sector_count_text(counters: Sequence[Dict[str, Any]], limit: int = 3) -> str:
chosen = counters[:limit]
if not chosen:
return "无数据"
return "、".join(f"{item.get('name', '未分类')}({item.get('count', 0)}只)" for item in chosen)
def render_strategy_facts(name: str, payload: Dict[str, Any]) -> str:
metrics = payload.get("median_metrics", {})
rows: List[List[str]] = [
["策略状态", "启用" if payload.get("enabled") else "未启用"],
["通过数量", str(payload.get("candidate_count", 0))],
["展示数量", str(payload.get("display_count", 0))],
["筛选阈值", thresholds_text(name, payload.get("thresholds", {}))],
["行业集中", sector_count_text(payload.get("sector_counts", []), 3)],
]
if name == "value":
rows.extend(
[
["候选PE中位数", x_text(metrics.get("pe"), 1)],
["候选PB中位数", x_text(metrics.get("pb"))],
["候选ROE中位数", unsigned_percent_text(metrics.get("roe"))],
]
)
elif name == "growth":
rows.extend(
[
["候选营收同比中位数", percent_text(metrics.get("revenue_yoy"))],
["候选净利润同比中位数", percent_text(metrics.get("profit_yoy"))],
["候选ROE中位数", unsigned_percent_text(metrics.get("roe"))],
]
)
else:
rows.extend(
[
["候选ROE中位数", unsigned_percent_text(metrics.get("roe"))],
["候选资产负债率中位数", unsigned_percent_text(metrics.get("debt_ratio"))],
["候选净利润中位数", billion_yuan_text(metrics.get("net_profit"))],
]
)
return format_table(["字段", "数值"], rows)
def render_strategy_candidates(name: str, candidates: Sequence[Dict[str, Any]]) -> str:
if name == "value":
headers = ["股票", "行业", "最新季度", "PE", "PB", "ROE", "市值"]
rows = [
[
f"{item.get('name', item.get('order_book_id', '-'))}<br>`{item.get('order_book_id', '-')}`",
item.get("sector", "未分类"),
item.get("latest_quarter", "无数据"),
x_text(item.get("pe"), 1),
x_text(item.get("pb")),
unsigned_percent_text(item.get("roe")),
billion_yuan_text(item.get("market_cap")),
]
for item in candidates
]
elif name == "growth":
headers = ["股票", "行业", "最新季度", "营收同比", "净利润同比", "ROE", "PE"]
rows = [
[
f"{item.get('name', item.get('order_book_id', '-'))}<br>`{item.get('order_book_id', '-')}`",
item.get("sector", "未分类"),
item.get("latest_quarter", "无数据"),
percent_text(item.get("revenue_yoy")),
percent_text(item.get("profit_yoy")),
unsigned_percent_text(item.get("roe")),
x_text(item.get("pe"), 1),
]
for item in candidates
]
else:
headers = ["股票", "行业", "最新季度", "ROE", "资产负债率", "净利润", "PB"]
rows = [
[
f"{item.get('name', item.get('order_book_id', '-'))}<br>`{item.get('order_book_id', '-')}`",
item.get("sector", "未分类"),
item.get("latest_quarter", "无数据"),
unsigned_percent_text(item.get("roe")),
unsigned_percent_text(item.get("debt_ratio")),
billion_yuan_text(item.get("net_profit")),
x_text(item.get("pb")),
]
for item in candidates
]
if not rows:
rows = [["无候选", "-", "-", "-", "-", "-", "-"]]
return format_table(headers, rows)
def render_metric_table(snapshot: Dict[str, Any]) -> str:
metrics = snapshot.get("universe", {}).get("median_metrics", {})
strategy_payloads = snapshot.get("strategies", {})
rows = [
["PE 中位数", x_text(metrics.get("pe"), 1), str(strategy_payloads.get("value", {}).get("candidate_count", 0))],
["PB 中位数", x_text(metrics.get("pb")), "-"],
["ROE 中位数", unsigned_percent_text(metrics.get("roe")), str(strategy_payloads.get("quality", {}).get("candidate_count", 0))],
["营收同比中位数", percent_text(metrics.get("revenue_yoy")), str(strategy_payloads.get("growth", {}).get("candidate_count", 0))],
["净利润同比中位数", percent_text(metrics.get("profit_yoy")), "-"],
["总市值中位数", billion_yuan_text(metrics.get("market_cap")), "-"],
]
return format_table(["指标", "全池参考", "关联候选数量"], rows)
def render_universe_facts(snapshot: Dict[str, Any]) -> str:
universe = snapshot.get("universe", {})
rows = [
["股票池规模", str(universe.get("stock_pool_size", 0))],
["财务快照覆盖", str(universe.get("valid_financials", 0))],
["ROE 覆盖", str(universe.get("valid_roe", 0))],
["PE 覆盖", str(universe.get("valid_pe", 0))],
["PB 覆盖", str(universe.get("valid_pb", 0))],
]
return format_table(["字段", "数值"], rows)
def render_overlap_facts(snapshot: Dict[str, Any]) -> str:
overlap = snapshot.get("overlap", {})
sectors = sector_count_text(overlap.get("sector_counts", []), 3)
rows = [
["交叉命中数量", str(overlap.get("candidate_count", 0))],
["行业集中", sectors],
]
return format_table(["字段", "数值"], rows)
def render_overlap_table(snapshot: Dict[str, Any]) -> str:
rows = [
[
f"{item.get('name', item.get('order_book_id', '-'))}<br>`{item.get('order_book_id', '-')}`",
item.get("sector", "未分类"),
" / ".join(item.get("strategies", [])),
unsigned_percent_text(item.get("roe")),
x_text(item.get("pe"), 1),
x_text(item.get("pb")),
percent_text(item.get("revenue_yoy")),
percent_text(item.get("profit_yoy")),
]
for item in snapshot.get("overlap", {}).get("display_candidates", [])
]
if not rows:
rows = [["无候选", "-", "-", "-", "-", "-", "-", "-"]]
return format_table(["股票", "行业", "命中策略", "ROE", "PE", "PB", "营收同比", "净利润同比"], rows)
def render_risk_facts(snapshot: Dict[str, Any]) -> str:
risk = snapshot.get("risk_flags", {})
top_sector = risk.get("top_selected_sector") or {}
rows = [
["缺失财务样本数", str(risk.get("missing_financial_count", 0))],
["当前候选去重数量", str(risk.get("selected_count", 0))],
[
"候选最集中行业",
f"{top_sector.get('name', '无数据')}({top_sector.get('count', 0)}次)" if top_sector else "无数据",
],
["成长候选PE中位数", x_text(risk.get("growth_pe_median"), 1)],
["价值/质量交集", str(risk.get("value_quality_overlap_count", 0))],
["价值/成长交集", str(risk.get("value_growth_overlap_count", 0))],
]
return format_table(["字段", "数值"], rows)
def render_appendix_facts(snapshot: Dict[str, Any]) -> str:
inputs = snapshot.get("inputs", {})
rows = [[name, str(value)] for name, value in inputs.items()]
if not rows:
rows = [["无数据", "-"]]
return format_table(["输入文件", "记录数"], rows)
def require_summaries(snapshot: Dict[str, Any]) -> Dict[str, str]:
summaries = snapshot.get("summaries") or {}
missing = [key for key in SUMMARY_KEYS if not str(summaries.get(key, "")).strip()]
if missing:
raise ValueError(
"结构化快照中的 summaries 缺失,需先由 LLM 回写以下字段:"
+ ", ".join(missing)
)
return {key: str(summaries[key]).strip() for key in SUMMARY_KEYS}
def render_template(template_text: str, replacements: Dict[str, str]) -> str:
report_text = template_text
for token, value in replacements.items():
report_text = report_text.replace(f"[[{token}]]", value)
unresolved = sorted(set(TOKEN_RE.findall(report_text)))
if unresolved:
raise ValueError(f"模板占位符未完全替换:{', '.join(unresolved)}")
for heading in REQUIRED_HEADINGS:
if heading not in report_text:
raise ValueError(f"模板缺少必需章节:{heading}")
return report_text
def try_render_html(md_path: Path) -> Optional[Path]:
renderer_binary = shutil.which("rq-report-renderer")
html_path = md_path.with_suffix(".html")
if renderer_binary:
try:
subprocess.run([renderer_binary, str(md_path), str(html_path)], check=True, capture_output=True, text=True)
print(f"✅ HTML 报告已生成:{html_path}")
return html_path
except subprocess.CalledProcessError as exc:
print(f"警告:rq-report-renderer 渲染失败:{exc}")
repo_renderer = Path(__file__).resolve().parents[2] / "report-renderer" / "scripts" / "render_report.py"
if repo_renderer.exists():
try:
subprocess.run(["python3", str(repo_renderer), str(md_path), str(html_path)], check=True, capture_output=True, text=True)
print(f"✅ HTML 报告已生成:{html_path}")
return html_path
except subprocess.CalledProcessError as exc:
print(f"警告:仓库内 render_report.py 执行失败:{exc}")
print("警告:未找到可用的 HTML 渲染器,保留 Markdown 输出")
return None
def main() -> None:
args = parse_args()
snapshot_path = Path(args.snapshot).expanduser() if args.snapshot else None
if snapshot_path is None:
if not args.data_dir:
raise ValueError("必须提供 --snapshot 或 --data-dir。")
snapshot_path = Path(args.data_dir).expanduser() / "idea_screening_snapshot.json"
snapshot = read_json_file(snapshot_path)
summaries = require_summaries(snapshot)
strategy_payloads = snapshot.get("strategies", {})
template_text = Path(args.template).read_text(encoding="utf-8")
report_text = render_template(
template_text,
{
"REPORT_DATE": str(snapshot.get("report_date", date.today().isoformat())),
"STRATEGY": str(snapshot.get("strategy", "all")),
"EXEC_SUMMARY": summaries["exec_summary"],
"UNIVERSE_OVERVIEW": summaries["universe_overview"],
"UNIVERSE_FACTS": render_universe_facts(snapshot),
"QUARTER_TABLE": render_counter_table("最新财报季度", snapshot.get("universe", {}).get("latest_quarters", [])),
"SECTOR_TABLE": render_counter_table("行业", snapshot.get("universe", {}).get("top_sectors", [])),
"METRIC_SCOREBOARD": summaries["metric_scoreboard"],
"METRIC_TABLE": render_metric_table(snapshot),
"VALUE_SUMMARY": summaries["value_section"],
"VALUE_FACTS": render_strategy_facts("value", strategy_payloads.get("value", {})),
"VALUE_TABLE": render_strategy_candidates("value", strategy_payloads.get("value", {}).get("display_candidates", [])),
"GROWTH_SUMMARY": summaries["growth_section"],
"GROWTH_FACTS": render_strategy_facts("growth", strategy_payloads.get("growth", {})),
"GROWTH_TABLE": render_strategy_candidates("growth", strategy_payloads.get("growth", {}).get("display_candidates", [])),
"QUALITY_SUMMARY": summaries["quality_section"],
"QUALITY_FACTS": render_strategy_facts("quality", strategy_payloads.get("quality", {})),
"QUALITY_TABLE": render_strategy_candidates("quality", strategy_payloads.get("quality", {}).get("display_candidates", [])),
"OVERLAP_SUMMARY": summaries["overlap_section"],
"OVERLAP_FACTS": render_overlap_facts(snapshot),
"OVERLAP_TABLE": render_overlap_table(snapshot),
"RISK_SUMMARY": summaries["risk_section"],
"RISK_FACTS": render_risk_facts(snapshot),
"APPENDIX_SUMMARY": summaries["appendix"],
"APPENDIX_FACTS": render_appendix_facts(snapshot),
},
)
output_path = Path(args.output).expanduser()
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(report_text, encoding="utf-8")
print(f"✅ Markdown 报告已生成:{output_path}")
if not args.no_render:
try_render_html(output_path)
if __name__ == "__main__":
main()
Related skills
How it compares
Structured screening report skill with fixed JSON contracts—not a generic stock-picking chat prompt or a single-factor screener UI.
FAQ
Who is rq-idea-generation for?
Independent quant and PMs on RiceQuant workflows who document A-share screens before committing to strategy code or capital.
When should I use rq-idea-generation?
Use it in Idea → research when you have stock_pool.json and latest_financials.json ready and need a [[REPORT_DATE]] memo covering value, growth, quality, and overlap names.
Is rq-idea-generation safe to install?
It processes local financial JSON you supply; review Security Audits on this Prism page and treat market data paths as sensitive operational assets, not as investment advice.