diff --git a/.cursor/skills/catalyst-calendar/SKILL.md b/.cursor/skills/catalyst-calendar/SKILL.md new file mode 100644 index 000000000..f42ea0089 --- /dev/null +++ b/.cursor/skills/catalyst-calendar/SKILL.md @@ -0,0 +1,364 @@ +--- +name: rq-catalyst-calendar +description: | + 创建模板驱动的催化剂日历报告,追踪覆盖股票池未来 30 天的重要事件和催化剂。 + 使用 RQData CLI 获取财报、分红、公告等结构化公司事件;当需要补充宏观、行业会议或政策催化时,可额外使用 `web_search` 获取实时信息,并先落为结构化 JSON,再统一生成 Markdown 和 HTML 报告。 + + 务必使用此技能当用户: + - 明确请求催化剂日历、事件日历、earnings calendar、upcoming events + - 想了解接下来一段时间的重要事件、本周/本月重点日期 + - 需要追踪股票池的重要催化剂 + - 需要把公司事件与宏观 / 行业催化放进同一份时间轴报告 + + 不适用场景: + - 单个公司财报深度分析 -> earnings-preview / earnings-analysis + - 行业整体分析 -> sector-overview + - 只需回答单一事实且不需要完整日历报告 +compatibility: "Requires rqdata CLI, Bash, python3. Report structure is managed by catalyst-calendar/assets/template.md." +--- + +# RQ 股票研究 - 催化剂日历 + +## 核心原则 + +- 所有内容必须遵循三阶段流程:数据采集 -> 报告生成 -> HTML 渲染 +- `assets/template.md` 是唯一报告模板来源;Python 只负责归一化、校验和填充固定占位符,不负责自由撰写长段正文 +- `RQData CLI` 是公司结构化事件主源;`web_search` 只能补充 CLI 无法直接提供的宏观 / 行业 / 会议信息 +- 所有结论必须数据驱动,不能写死日期、季度、股票代码、公司名称 +- 没有精确事件日期时,只能输出预计窗口,不能伪造具体日期 +- 每个关键事件、关键判断和跟踪建议都必须标注数据来源和置信度 + +## 数据源分工 + +### `RQData CLI` 负责 + +- 财报相关日期、业绩快报后续正式披露窗口 +- 分红、股权登记、除权除息 +- 上市公司公告、股东大会、董事会 / 监事会决议、资本运作公告 +- 基础证券信息、股票池、公司名称映射 + +### `web_search` 负责 + +- 宏观数据发布日期和政策会议日程 +- 行业会议、论坛、展会、监管征求意见、主题催化 +- RQData CLI 无法直接提供、但会影响覆盖股票池的实时外部催化 + +### `web_search` 禁止替代的内容 + +- 财务数据、估值、股价、成交量等结构化市场数据 +- 上市公司公告、分红、股东大会、正式财报发布日期 +- 任何可以直接通过 RQData CLI 或交易所 / 公司官方披露获得的结构化公司事件 + +## `web_search` 使用规则 + +详细检索口径、结构化字段、来源等级、落盘示例和执行要求见 [references/web_search.md](references/web_search.md)。 + +### 允许的补充内容 + +- 宏观:CPI / PPI / GDP / PMI / LPR / 利率会议 / 政策发布时间 +- 行业:会议、论坛、主题展会、监管政策日程、行业统计发布时间 +- 主题催化:影响股票池的跨公司事件,例如补贴政策窗口、行业标准落地、重要招标或牌照审批时点 + +### 结构化落盘要求 + +通过 `web_search` 获取的信息,必须先写入 `web_search_events.json`。 + +说明: + +- `event_scope` 仅允许 `macro` 或 `industry` +- `date_type=exact` 时必须提供 `event_date` +- `date_type=estimated_window` 时必须提供 `window_start` 与 `window_end` +- `web_search` 获取的预计窗口事件,置信度不得高于 `3` + +### fallback 规则 + +1. 优先使用当前环境原生可用的 `web_search` 工具。 +2. 若无原生 `web_search`,fallback 到当前环境已配置的联网检索工具。 +3. 若仍无法联网: + - 不得伪造实时宏观 / 行业事件 + - 不得用训练记忆补日期 + - 在报告中明确写出该部分缺失 + - 相关判断降为最低可交付置信度,或直接标为未验证 + +## 硬性规则 + +以下任一条违反,视为输出失败: + +- `[MUST-1]` 公司结构化事件必须优先来自 `RQData CLI` +- `[MUST-2]` `web_search` 不得替代财报、分红、公告、股东大会等公司正式事件主源 +- `[MUST-3]` 公告分类必须基于标题语义和原文校验,不能依赖不透明数字分类码 +- `[MUST-4]` 没有精确日期时只能写预计窗口,不能把窗口伪装成确定日期 +- `[MUST-5]` 所有关键事件都要写 `数据来源:XXX,置信度X` +- `[MUST-6]` 预计窗口、未验证信息和推断信息不得作为核心高确信度结论 +- `[MUST-7]` 同一事件要做去重、来源校验和日期语义校验,不能把披露日误写成事件发生日 +- `[MUST-8]` 若过去公告已明确未来日期,必须按实际事件日纳入日历,同时保留原披露日和原文链接 +- `[MUST-9]` 最终输出必须严格来自模板,不得在脚本中自由拼写整篇报告 + +## 确信度评级 + +- `5`:RQData CLI、交易所公告、上市公司官网、官方监管披露 +- `4`:政府部门、行业协会、官方机构、权威财经媒体,且时间和内容明确 +- `3`:一般新闻源或二手整理,但出处明确且与其他来源一致;或 `web_search` 的预计窗口事件 +- `2`:单一来源、细节不完整、日期仍待核实 +- `1`:推断、估算窗口、未验证信息 + +使用规则: + +- 混合结论的置信度取关键来源中的最低值 +- `web_search` 的预计窗口事件最高只能标 `3` +- 低置信度信息不能成为高影响事项的唯一依据 + +## 图表 / 图片需求 + +本 skill 的推荐图示为“催化剂时间轴图”,但允许在图表无法生成时降级为表格,不把图表能力写成当前脚本做不到的强制要求。 + +- 图表名称:催化剂时间轴图 +- 图表目的:把未来 30 天高影响事件按日期铺开,帮助识别事件密集日和连续催化链条 +- 使用的数据文件:`announcement_raw.json`、`earnings_express_raw.json`、`latest_financial_raw.json`、`dividend_raw.json`、可选 `web_search_events.json` +- 关键字段:`event_date`、`window_start`、`window_end`、`event_type`、`impact_level`、`company/scope_name` +- 建议图表类型:横向时间轴或甘特式时间带 +- 时间范围:`--start-date` 到 `--end-date` +- 图表应回答的问题:未来 30 天哪几天最密集、哪些事件需要提前准备、哪些只有窗口没有精确日期 +- 报告放置位置:`## 完整日历视图` 之前或同节内 +- 若图表缺失:必须保留 `完整日历视图` 表格和 `日期不确定事件` 表格作为降级交付 + +## 目标产出 + +- 报告长度:5-8 页 +- 目标字数:2,000-3,000 字 +- 输出文件: + - Markdown 报告 + - HTML 报告(若本地已安装 `rq-report-renderer`) +- 报告目录:用户通过 `--data-dir` 和 `--output` 指定,不写死固定路径 + +## 目录结构 + +```text +catalyst-calendar/ +├── SKILL.md +├── scripts/ +│ └── generate_report.py +├── assets/ +│ └── template.md +└── references/ + └── data_contract.md +``` + +## 输入文件契约 + +原始数据目录由 `--data-dir` 指定,脚本会优先读取下列文件: + +- `stock_pool.json` +- `earnings_express_raw.json` +- `latest_financial_raw.json` +- `dividend_raw.json` +- `instrument_meta.json` +- `announcement_raw.json` +- `web_search_events.json`(可选,仅用于宏观 / 行业催化补充) + +文件结构默认兼容 RQData CLI 常见输出: + +- 顶层为对象,核心数据放在 `data` 字段 +- `data` 可以是列表,也可以是单个对象 +- 股票字段常见命名: + - `order_book_id` + - `ticker` + - `stock_code` + - `symbol` +- 公司名字段常见命名: + - `name` + - `display_name` + - `stock_name` + - `company_name` + - `symbol` + +完整字段说明见 [references/data_contract.md](references/data_contract.md)。 + +## 工作流 + +### 步骤 1:准备参数 + +```bash +AS_OF_DATE="$(date +%F)" +START_DATE="$AS_OF_DATE" +INDEX_ID="${INDEX_ID:-000300.XSHG}" +ORDER_BOOK_IDS_JSON="${ORDER_BOOK_IDS_JSON:-[\"000001.XSHE\",\"600000.XSHG\"]}" +START_QUARTER="${START_QUARTER:-2025q1}" +END_QUARTER="${END_QUARTER:-2025q4}" +DIVIDEND_LOOKBACK_START="$(python3 - <<'PY' +from datetime import date, timedelta +print((date.today() - timedelta(days=365)).isoformat()) +PY +)" +ANNOUNCEMENT_LOOKBACK_START="$(python3 - <<'PY' +from datetime import date, timedelta +print((date.today() - timedelta(days=120)).isoformat()) +PY +)" +END_DATE="$(python3 - <<'PY' +from datetime import date, timedelta +print((date.today() + timedelta(days=30)).isoformat()) +PY +)" + +DATA_DIR="${DATA_DIR:-$HOME/rq_equities_reports/catalyst_calendar}" +OUTPUT_MD="${OUTPUT_MD:-$DATA_DIR/catalyst_calendar_${START_DATE}_${END_DATE}.md}" +``` + +### 步骤 2:采集原始数据 + +以下文件名是脚本默认识别的输入文件名。字段名需要和实际 RQData 返回保持一致,必要时先用 `rqdata help ` 检查。 + +```bash +mkdir -p "$DATA_DIR" + +# 示例:股票池 +rqdata index constituents --payload "{ + \"order_book_id\": \"$INDEX_ID\", + \"date\": \"$AS_OF_DATE\" +}" --format json > "$DATA_DIR/stock_pool.json" + +# 示例:业绩快报(以观察起始日为锚点,抓取最近披露的快报) +rqdata stock cn financial-express --payload "{ + \"order_book_ids\": $ORDER_BOOK_IDS_JSON, + \"info_date\": \"$START_DATE\", + \"interval\": \"4q\" +}" --format json > "$DATA_DIR/earnings_express_raw.json" + +# 示例:最新正式财报(用于判断快报对应报告期是否已经被正式财报覆盖) +rqdata stock cn financial --payload "{ + \"order_book_ids\": $ORDER_BOOK_IDS_JSON, + \"fields\": [\"revenue\"], + \"start_quarter\": \"$START_QUARTER\", + \"end_quarter\": \"$END_QUARTER\", + \"statements\": \"all\" +}" --format json > "$DATA_DIR/latest_financial_raw.json" + +# 示例:分红相关原始数据 +rqdata stock cn dividend --payload "{ + \"order_book_ids\": $ORDER_BOOK_IDS_JSON, + \"start_date\": \"$DIVIDEND_LOOKBACK_START\", + \"end_date\": \"$END_DATE\" +}" --format json > "$DATA_DIR/dividend_raw.json" + +# 示例:基础信息(用于补公司名称) +rqdata stock cn instruments --payload "{ + \"order_book_ids\": $ORDER_BOOK_IDS_JSON +}" --format json > "$DATA_DIR/instrument_meta.json" + +# 示例:公告数据(用于精确识别财报、股东大会、利润分配等事件) +rqdata stock cn announcement --payload "{ + \"order_book_ids\": $ORDER_BOOK_IDS_JSON, + \"start_date\": \"$ANNOUNCEMENT_LOOKBACK_START\", + \"end_date\": \"$END_DATE\" +}" --format json > "$DATA_DIR/announcement_raw.json" +``` + +### 步骤 2B:补充 `web_search` 宏观 / 行业事件 + +仅当需要补充实时宏观或行业催化,且这些信息无法直接通过 `RQData CLI` 获得时,才执行此步骤。 + +执行要求: + +- 先检索,再整理,最后落为 `web_search_events.json` +- 每条记录都要保留来源、链接、发布时间、检索时间和相关性说明 +- 不得把检索到的新闻标题直接当作结构化事件,必须手工或脚本抽取 `event_date` 或 `window_start/window_end` + +### 步骤 3:生成 Markdown 报告 + +```bash +python3 catalyst-calendar/scripts/generate_report.py \ + --data-dir "$DATA_DIR" \ + --start-date "$START_DATE" \ + --end-date "$END_DATE" \ + --output "$OUTPUT_MD" +``` + +常用参数: + +- `--data-dir`:原始 JSON 数据目录 +- `--start-date`:观察窗口开始日期 +- `--end-date`:观察窗口结束日期 +- `--output`:输出 Markdown 路径 +- `--template`:自定义模板路径,默认 `catalyst-calendar/assets/template.md` +- `--report-date`:报告日期,默认当天 +- `--no-render`:不生成 HTML + +### 步骤 4:渲染 HTML + +脚本默认会尝试使用本地安装的 `rq-report-renderer` 渲染 HTML。如果本地没有可用渲染器,脚本会保留 Markdown 并打印警告。 + +## 阶段门控 + +### 阶段 1:数据采集完成标准 + +- RQData 原始文件齐全,或缺失原因已明确记录 +- `announcement_raw.json` 的时间范围包含观察起始日前至少 120 天 +- 若使用 `web_search`,`web_search_events.json` 已按结构化字段落盘 +- 未验证日期、模糊窗口和推断信息已被单独标识 + +### 阶段 2:报告生成完成标准 + +- 所有未来事件都完成去重 +- 公告披露日与实际事件日已做语义区分 +- 预计窗口事件未被写成精确日期 +- 模板占位符全部替换完成 +- 关键事件后都有 `数据来源:XXX,置信度X` + +### 阶段 3:交付完成标准 + +- Markdown 报告生成成功 +- 若渲染器可用,HTML 报告生成成功 +- 关键章节齐全 +- 若图表缺失,已通过表格完成降级交付 + +## 模板规则 + +- 报告必须严格基于 [template.md](assets/template.md) 生成 +- 模板占位符采用 `[[TOKEN]]` 语法,不使用 Jinja +- 当前模板仅允许以下占位符: + - `[[REPORT_DATE]]` + - `[[START_DATE]]` + - `[[END_DATE]]` + - `[[COVERAGE_SCOPE]]` + - `[[EXEC_SUMMARY]]` + - `[[CALENDAR_TABLE]]` + - `[[EVENT_TYPE_SECTIONS]]` + - `[[HIGH_IMPACT_SECTIONS]]` + - `[[RECENT_DISCLOSED_SECTIONS]]` + - `[[ACTION_SECTION]]` + - `[[ESTIMATED_EVENT_SECTIONS]]` + - `[[APPENDIX]]` + +## 报告质量要求 + +- 完整包含模板中的主章节 +- 每个关键数据块后必须有来源说明 +- 不得出现硬编码示例值残留 +- 不得将预计窗口写成精确日期 +- 高影响事件必须给出事件依据和关注点 +- 若过去公告已明确未来日期,正文应按实际事件日展示,而不是按公告披露日展示 +- 公告处理应先做标题筛选;只有高概率携带未来日期或期间的公告才需要读取原文 +- 正文必须保留可追溯的公告原文链接 +- 宏观 / 行业事件若来自 `web_search`,必须保留来源名称、URL、发布时间和检索时间 +- 无事件时必须明确写出“无数据 / 无事件”,不能输出空章节 + +## 阶段验收清单 + +- [ ] 数据采集 -> 报告生成 -> HTML 渲染三阶段都按顺序执行 +- [ ] `RQData CLI` 与 `web_search` 的边界没有混用 +- [ ] `web_search` 事件已写入结构化 JSON,而不是散落在自然语言笔记里 +- [ ] 公司事件与宏观 / 行业事件都保留来源和置信度 +- [ ] 事件去重、来源校验、日期语义校验已完成 +- [ ] 时间轴图若未生成,表格降级路径仍能支撑报告质量 +- [ ] Markdown / HTML 输出达到 5-8 页目标的可交付质量 + +## 常见错误 + +- 直接在 Python 里拼接整篇报告正文 +- 用固定季度映射伪造财报发布日期 +- 把十大股东数据误当作股东大会日期来源 +- 把分红预案、股权登记、除权除息混成一个事件 +- 使用网络搜索替代 `RQData` 的正式公司事件 +- 用新闻发布时间代替真实事件发生日期 +- 使用固定输出目录而不是 `--data-dir` / `--output` diff --git a/.cursor/skills/catalyst-calendar/assets/template.md b/.cursor/skills/catalyst-calendar/assets/template.md new file mode 100644 index 000000000..34878a0da --- /dev/null +++ b/.cursor/skills/catalyst-calendar/assets/template.md @@ -0,0 +1,29 @@ +# 催化剂日历报告 + +报告日期:[[REPORT_DATE]] +观察区间:[[START_DATE]] 至 [[END_DATE]] +覆盖范围:[[COVERAGE_SCOPE]] + +## 执行摘要 +[[EXEC_SUMMARY]] + +## 完整日历视图 +[[CALENDAR_TABLE]] + +## 按事件类型分类 +[[EVENT_TYPE_SECTIONS]] + +## 高影响事件详解 +[[HIGH_IMPACT_SECTIONS]] + +## 近期已披露催化剂 +[[RECENT_DISCLOSED_SECTIONS]] + +## 跟踪建议 +[[ACTION_SECTION]] + +## 日期不确定事件 +[[ESTIMATED_EVENT_SECTIONS]] + +## 附录:口径说明 +[[APPENDIX]] diff --git a/.cursor/skills/catalyst-calendar/references/data_contract.md b/.cursor/skills/catalyst-calendar/references/data_contract.md new file mode 100644 index 000000000..03fcbee5f --- /dev/null +++ b/.cursor/skills/catalyst-calendar/references/data_contract.md @@ -0,0 +1,145 @@ +# Catalyst Calendar Data Contract + +## Raw Inputs + +The generator looks for these files inside `--data-dir`: + +- `stock_pool.json` +- `earnings_express_raw.json` +- `latest_financial_raw.json` +- `dividend_raw.json` +- `instrument_meta.json` +- `announcement_raw.json` +- `web_search_events.json` (optional) + +Each file may be: + +- `{ "data": [...] }` +- `{ "data": { ... } }` +- `[...]` +- `{ ... }` + +## Common Identifier Fields + +Ticker fields: + +- `order_book_id` +- `ticker` +- `stock_code` +- `symbol` +- `code` + +Company name fields: + +- `display_name` +- `name` +- `stock_name` +- `company_name` +- `symbol_name` +- `symbol` + +## `web_search_events.json` Contract + +`web_search_events.json` is only for macro / industry catalysts that cannot be directly sourced from `RQData CLI`. + +Each record must contain at least: + +- `query` +- `source_name` +- `source_type` +- `title` +- `url` +- `published_at` +- `retrieved_at` +- `summary` +- `why_relevant` +- `confidence` +- `event_scope` +- `scope_name` +- `event_title` +- `date_type` +- `impact_level` + +Date fields: + +- `date_type = exact` requires `event_date` +- `date_type = estimated_window` requires both `window_start` and `window_end` + +Allowed `event_scope` values: + +- `macro` +- `industry` + +Recommended `source_type` values: + +- `official` +- `government` +- `association` +- `authoritative_media` +- `general_news` +- `inference` + +Example: + +```json +{ + "data": [ + { + "query": "2026年4月 CPI 发布日期 国家统计局", + "source_name": "国家统计局", + "source_type": "government", + "title": "2026年4月居民消费价格数据发布时间安排", + "url": "https://www.stats.gov.cn/...", + "published_at": "2026-04-01", + "retrieved_at": "2026-04-07", + "summary": "国家统计局更新了 4 月价格数据发布时间。", + "why_relevant": "宏观数据发布可能影响利率预期和高估值板块风险偏好。", + "confidence": 4, + "event_scope": "macro", + "scope_name": "宏观", + "event_title": "4月 CPI / PPI 数据发布", + "date_type": "exact", + "event_date": "2026-04-10", + "impact_level": "H" + } + ] +} +``` + +## Normalized Event Fields + +Each normalized event contains: + +- `event_id` +- `ticker` +- `company` +- `event_type` +- `event_title` +- `event_date` +- `date_type` +- `window_start` +- `window_end` +- `impact_level` +- `source` +- `confidence` +- `evidence` +- `notes` +- `disclosure_date` +- `source_link` + +## Validation Rules + +- Company structured events should come from `RQData CLI` +- `web_search_events.json` must not be used to replace earnings dates, dividend dates, or official announcement events +- Web-search estimated windows must not exceed confidence `3` +- If a past announcement explicitly states a future meeting or activity date, the normalized `event_date` should use the actual future date, while `disclosure_date` keeps the original announcement date +- Raw announcement PDFs should be read selectively: only titles that are likely to contain useful future dates or windows should trigger PDF parsing + +## Date Rules + +- `date_type = exact`: the event has an explicit date from source data +- `date_type = estimated_window`: the event only has an expected disclosure or occurrence window +- When only a quarter or period is known, the generator must use an estimated window instead of a fabricated exact date +- Dividend events should be queried with a long enough lookback window, but should only enter the calendar when `ex_dividend_date` falls inside the observation window +- Announcement events should be queried with an additional lookback before the report start date; for catalyst-calendar, about 120 days is recommended because past announcements may already contain exact future event dates +- For web events, `published_at` is the disclosure / publication date of the source, not the event occurrence date diff --git a/.cursor/skills/catalyst-calendar/references/web_search.md b/.cursor/skills/catalyst-calendar/references/web_search.md new file mode 100644 index 000000000..28627b915 --- /dev/null +++ b/.cursor/skills/catalyst-calendar/references/web_search.md @@ -0,0 +1,102 @@ +# Catalyst Calendar Web Search Reference + +## Purpose + +Use `web_search` only to supplement macro / industry catalysts that `RQData CLI` cannot directly provide. + +## Allowed Coverage + +- Macro release dates and policy meeting schedules +- Industry conferences, forums, exhibitions, consultation windows, regulatory milestones +- Cross-company thematic catalysts that may affect the covered stock pool + +## Prohibited Usage + +- Do not use `web_search` to replace earnings dates, dividends, exchange announcements, shareholder meetings, or other formal company events +- Do not use `web_search` to replace structured financial, valuation, or price data +- Do not fill missing real-time dates from memory + +## Required Output File + +All web-searched catalysts must be written to `web_search_events.json`. + +Each record must contain: + +- `query` +- `source_name` +- `source_type` +- `title` +- `url` +- `published_at` +- `retrieved_at` +- `summary` +- `why_relevant` +- `confidence` +- `event_scope` +- `scope_name` +- `event_title` +- `date_type` +- `event_date` or `window_start` + `window_end` +- `impact_level` + +## Field Rules + +- `event_scope` only allows `macro` or `industry` +- `date_type=exact` requires `event_date` +- `date_type=estimated_window` requires `window_start` and `window_end` +- Estimated-window web events cannot have confidence above `3` +- `published_at` is the source publication date, not the event date +- `retrieved_at` is the actual search / retrieval date + +## Source Types And Confidence Ceiling + +- `official`: max confidence `5` +- `government`: max confidence `4` +- `association`: max confidence `4` +- `authoritative_media`: max confidence `4` +- `general_news`: max confidence `3` +- `inference`: max confidence `1` + +## Search Workflow + +1. Search only for information `RQData CLI` cannot directly supply. +2. Prefer official and primary sources first. +3. Extract the actual event date or expected window from the source. +4. Write the result to `web_search_events.json` with source metadata and relevance notes. +5. If the date is still not confirmed, downgrade to `estimated_window` and lower confidence. + +## 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 dates + - mark the item as unavailable or unverified + - do not upgrade confidence artificially + +## Example + +```json +{ + "data": [ + { + "query": "2026年4月 CPI 发布日期 国家统计局", + "source_name": "国家统计局", + "source_type": "government", + "title": "2026年4月居民消费价格数据发布时间安排", + "url": "https://www.stats.gov.cn/...", + "published_at": "2026-04-01", + "retrieved_at": "2026-04-07", + "summary": "国家统计局更新了 4 月价格数据发布时间。", + "why_relevant": "宏观数据发布可能影响利率预期和高估值板块风险偏好。", + "confidence": 4, + "event_scope": "macro", + "scope_name": "宏观", + "event_title": "4月 CPI / PPI 数据发布", + "date_type": "exact", + "event_date": "2026-04-10", + "impact_level": "H" + } + ] +} +``` diff --git a/.cursor/skills/catalyst-calendar/scripts/generate_report.py b/.cursor/skills/catalyst-calendar/scripts/generate_report.py new file mode 100644 index 000000000..e19f00630 --- /dev/null +++ b/.cursor/skills/catalyst-calendar/scripts/generate_report.py @@ -0,0 +1,1725 @@ +#!/usr/bin/env python3 +"""Template-driven catalyst calendar report generator.""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import sys +import zlib +from collections import Counter, defaultdict +from dataclasses import dataclass +from datetime import date, datetime, timedelta +from pathlib import Path +from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple + +import requests + + +TOKEN_RE = re.compile(r"\[\[([A-Z0-9_]+)\]\]") +REQUIRED_HEADINGS = [ + "# 催化剂日历报告", + "## 执行摘要", + "## 完整日历视图", + "## 按事件类型分类", + "## 高影响事件详解", + "## 近期已披露催化剂", + "## 跟踪建议", + "## 日期不确定事件", + "## 附录:口径说明", +] + +TICKER_FIELDS = ("order_book_id", "ticker", "stock_code", "symbol", "code") +NAME_FIELDS = ("display_name", "name", "stock_name", "company_name", "symbol_name", "symbol") +WEB_SOURCE_TYPE_LABELS = { + "official": "上市公司公告 / 官方网站 / 交易所披露", + "government": "政府 / 监管 / 行业协会 / 官方机构", + "association": "政府 / 监管 / 行业协会 / 官方机构", + "authoritative_media": "权威财经媒体", + "general_news": "一般新闻源", + "inference": "分析推断 / 未验证信息", +} +WEB_SOURCE_TYPE_MAX_CONFIDENCE = { + "official": 5, + "government": 4, + "association": 4, + "authoritative_media": 4, + "general_news": 3, + "inference": 1, +} +USER_AGENT = ( + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" +) +ACW_POS_LIST = [ + 0x0F, 0x23, 0x1D, 0x18, 0x21, 0x10, 0x01, 0x26, 0x0A, 0x09, + 0x13, 0x1F, 0x28, 0x1B, 0x16, 0x17, 0x19, 0x0D, 0x06, 0x0B, + 0x27, 0x12, 0x14, 0x08, 0x0E, 0x15, 0x20, 0x1A, 0x02, 0x1E, + 0x07, 0x04, 0x11, 0x05, 0x03, 0x1C, 0x22, 0x25, 0x0C, 0x24, +] +ACW_MASK = "3000176000856006061501533003690027800375" +OBJ_RE = re.compile(rb"(\d+)\s+(\d+)\s+obj\b(.*?)endobj", re.S) +STREAM_RE = re.compile(rb"<<(.*?)>>\s*stream\r?\n(.*?)\r?\nendstream", re.S) +PAGE_RE = re.compile(rb"/Type\s*/Page\b") +TEXT_OP_RE = re.compile( + r"/([A-Za-z0-9]+)\s+[0-9.]+\s+Tf|" + r"<([0-9A-Fa-f\s]+)>\s*Tj|" + r"\[(.*?)\]\s*TJ|" + r"\(((?:\\.|[^\\)])*)\)\s*Tj|" + r"(-?[0-9.]+)\s+(-?[0-9.]+)\s+T[Dd]|" + r"T\*|BT|ET", + re.S, +) + + +@dataclass +class Event: + event_id: str + ticker: str + company: str + event_type: str + event_title: str + event_date: Optional[date] + date_type: str + window_start: Optional[date] + window_end: Optional[date] + impact_level: str + source: str + confidence: int + evidence: str + notes: str + origin: str + disclosure_date: Optional[date] = None + source_link: str = "" + + def display_date(self) -> str: + if self.date_type == "exact" and self.event_date: + return self.event_date.isoformat() + if self.window_start and self.window_end: + return f"预计 {self.window_start.isoformat()} 至 {self.window_end.isoformat()}" + return "待确认" + + def date_type_label(self) -> str: + return "精确日期" if self.date_type == "exact" else "预计窗口" + + def sort_key(self) -> Tuple[date, int, str, str]: + anchor = self.event_date or self.window_start or date.max + estimated_rank = 0 if self.date_type == "exact" else 1 + return (anchor, estimated_rank, self.event_type, self.ticker) + + +def parse_args() -> argparse.Namespace: + skill_dir = Path(__file__).resolve().parent.parent + parser = argparse.ArgumentParser(description="生成模板驱动的催化剂日历报告") + parser.add_argument("--data-dir", required=True, help="原始 JSON 数据目录") + parser.add_argument("--start-date", required=True, help="观察开始日期 (YYYY-MM-DD)") + parser.add_argument("--end-date", required=True, help="观察结束日期 (YYYY-MM-DD)") + parser.add_argument("--output", help="输出 Markdown 文件路径") + parser.add_argument( + "--template", + default=str(skill_dir / "assets" / "template.md"), + help="Markdown 模板路径", + ) + parser.add_argument( + "--report-date", + default=date.today().isoformat(), + help="报告日期,默认当天", + ) + parser.add_argument( + "--no-render", + action="store_true", + help="不尝试渲染 HTML", + ) + 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 + + candidates = [ + "%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", + ] + for fmt in candidates: + try: + return datetime.strptime(text, fmt).date() + except ValueError: + continue + + text = text[:10] + try: + return datetime.strptime(text, "%Y-%m-%d").date() + except ValueError: + return None + + +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 markdown_link(label: str, url: str) -> str: + return f"[{label}]({url})" if url else label + + +def calc_sse_acw_cookie(arg1: str) -> str: + out = [""] * len(ACW_POS_LIST) + for idx, char in enumerate(arg1): + for out_idx, pos in enumerate(ACW_POS_LIST): + if pos == idx + 1: + out[out_idx] = char + break + arg2 = "".join(out) + pieces = [] + for idx in range(0, min(len(arg2), len(ACW_MASK)), 2): + pieces.append(f"{int(arg2[idx:idx + 2], 16) ^ int(ACW_MASK[idx:idx + 2], 16):02x}") + return "".join(pieces) + + +def fetch_pdf_bytes(url: str, timeout: float = 20.0) -> Tuple[Optional[bytes], str]: + referer = "https://disc.static.szse.cn/" if "szse.cn" in url else ("http://www.sse.com.cn/" if "sse.com.cn" in url else url) + headers = {"User-Agent": USER_AGENT, "Accept": "application/pdf,text/html,*/*", "Referer": referer} + session = requests.Session() + + try: + response = session.get(url, timeout=timeout, headers=headers, allow_redirects=True) + except requests.RequestException as exc: + return None, f"network_error:{type(exc).__name__}" + + content_type = (response.headers.get("content-type") or "").lower() + if response.ok and (content_type.startswith("application/pdf") or response.content.startswith(b"%PDF-")): + return response.content, "ok" + + if "static.sse.com.cn" in response.url and "text/html" in content_type: + match = re.search(r"arg1='([^']+)'", response.text) + if not match: + return None, "source_blocked:sse_html_without_arg1" + session.cookies.set("acw_sc__v2", calc_sse_acw_cookie(match.group(1)), domain="static.sse.com.cn", path="/") + try: + retry = session.get( + url, + timeout=timeout, + headers={"User-Agent": USER_AGENT, "Accept": "application/pdf,*/*", "Referer": "http://www.sse.com.cn/"}, + allow_redirects=True, + ) + except requests.RequestException as exc: + return None, f"network_error:{type(exc).__name__}" + retry_type = (retry.headers.get("content-type") or "").lower() + if retry.ok and (retry_type.startswith("application/pdf") or retry.content.startswith(b"%PDF-")): + return retry.content, "ok" + return None, f"source_blocked:sse_retry_{retry.status_code}" + + if not response.ok: + return None, f"http_{response.status_code}" + return None, f"unsupported_content_type:{content_type or 'unknown'}" + + +def parse_pdf_objects(pdf_bytes: bytes) -> Dict[int, bytes]: + return {int(match.group(1)): match.group(3) for match in OBJ_RE.finditer(pdf_bytes)} + + +def parse_stream(raw_object: bytes) -> Tuple[Optional[bytes], Optional[bytes]]: + match = STREAM_RE.search(raw_object) + if not match: + return None, None + stream_dict = match.group(1) + stream_data = match.group(2) + if b"/FlateDecode" in stream_dict: + stream_data = zlib.decompress(stream_data) + return stream_dict, stream_data + + +def decode_utf16be_hex(value: str) -> str: + return bytes.fromhex(value).decode("utf-16-be", "ignore") + + +def build_cmap(stream_text: str) -> Dict[str, str]: + cmap: Dict[str, str] = {} + for block in re.findall(r"beginbfchar\s*(.*?)\s*endbfchar", stream_text, re.S): + for src, dst in re.findall(r"<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>", block): + cmap[src.upper()] = decode_utf16be_hex(dst) + for block in re.findall(r"beginbfrange\s*(.*?)\s*endbfrange", stream_text, re.S): + for start, end, dst in re.findall(r"<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>", block): + start_int = int(start, 16) + end_int = int(end, 16) + dst_int = int(dst, 16) + width = len(start) + out_len = len(dst) // 2 + for idx, code in enumerate(range(start_int, end_int + 1)): + cmap[f"{code:0{width}X}"] = (dst_int + idx).to_bytes(out_len, "big").decode("utf-16-be", "ignore") + for start, _end, arr in re.findall(r"<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>\s*\[(.*?)\]", block, re.S): + start_int = int(start, 16) + width = len(start) + for idx, dst in enumerate(re.findall(r"<([0-9A-Fa-f]+)>", arr)): + cmap[f"{start_int + idx:0{width}X}"] = decode_utf16be_hex(dst) + return cmap + + +def decode_pdf_hex(hex_text: str, cmap: Dict[str, str]) -> str: + hex_text = re.sub(r"\s+", "", hex_text) + if not hex_text: + return "" + key_lengths = sorted({len(key) for key in cmap}, reverse=True) if cmap else [2] + cursor = 0 + output: List[str] = [] + while cursor < len(hex_text): + matched = False + for width in key_lengths: + key = hex_text[cursor:cursor + width].upper() + if len(key) == width and key in cmap: + output.append(cmap[key]) + cursor += width + matched = True + break + if matched: + continue + chunk = hex_text[cursor:cursor + 2] + if len(chunk) == 2: + try: + output.append(bytes.fromhex(chunk).decode("latin1")) + except ValueError: + pass + cursor += 2 + return "".join(output) + + +def decode_pdf_literal(text: str) -> str: + return ( + text.replace(r"\(", "(") + .replace(r"\)", ")") + .replace(r"\n", "\n") + .replace(r"\r", "") + .replace(r"\t", "\t") + .replace(r"\\", "\\") + ) + + +def extract_pdf_text(pdf_bytes: bytes) -> str: + objects = parse_pdf_objects(pdf_bytes) + font_cmaps: Dict[int, Dict[str, str]] = {} + for obj_num, raw_object in objects.items(): + match = re.search(rb"/ToUnicode\s+(\d+)\s+0\s+R", raw_object) + if not match: + continue + stream_ref = int(match.group(1)) + if stream_ref not in objects: + continue + _stream_dict, stream_data = parse_stream(objects[stream_ref]) + if not stream_data: + continue + font_cmaps[obj_num] = build_cmap(stream_data.decode("latin1", "ignore")) + + pages: List[Tuple[int, List[int], Dict[str, int]]] = [] + for obj_num, raw_object in objects.items(): + if not PAGE_RE.search(raw_object): + continue + font_refs = {name.decode("latin1"): int(ref) for name, ref in re.findall(rb"/([A-Za-z0-9]+)\s+(\d+)\s+0\s+R", raw_object)} + contents = [int(ref) for ref in re.findall(rb"/Contents\s+(\d+)\s+0\s+R", raw_object)] + if not contents: + array_match = re.search(rb"/Contents\s*\[(.*?)\]", raw_object, re.S) + if array_match: + contents = [int(ref) for ref in re.findall(rb"(\d+)\s+0\s+R", array_match.group(1))] + if contents: + pages.append((obj_num, contents, font_refs)) + pages.sort(key=lambda item: item[0]) + + output_lines: List[str] = [] + for _page_num, content_refs, font_refs in pages: + current_font = None + current_line: List[str] = [] + page_lines: List[str] = [] + + def flush_line() -> None: + nonlocal current_line + text = "".join(current_line).strip() + if text: + page_lines.append(text) + current_line = [] + + for ref in content_refs: + raw_object = objects.get(ref) + if raw_object is None: + continue + _stream_dict, stream_data = parse_stream(raw_object) + if not stream_data: + continue + stream_text = stream_data.decode("latin1", "ignore") + for match in TEXT_OP_RE.finditer(stream_text): + if match.group(1): + font_name = match.group(1) + current_font = font_refs.get(font_name, current_font) + elif match.group(2): + cmap = font_cmaps.get(current_font, {}) + current_line.append(decode_pdf_hex(match.group(2), cmap)) + elif match.group(3): + cmap = font_cmaps.get(current_font, {}) + for hex_group, literal in re.findall(r"<([0-9A-Fa-f\s]+)>|\(((?:\\.|[^\\)])*)\)", match.group(3), re.S): + if hex_group: + current_line.append(decode_pdf_hex(hex_group, cmap)) + elif literal: + current_line.append(decode_pdf_literal(literal)) + elif match.group(4): + current_line.append(decode_pdf_literal(match.group(4))) + else: + flush_line() + flush_line() + if page_lines: + output_lines.extend(page_lines) + return "\n".join(output_lines) + + +def normalize_pdf_text(text: str) -> str: + text = text.replace("\u3000", " ") + text = text.replace("\x00", "") + text = re.sub(r"(?<=\d)\s+(?=\d)", "", text) + text = re.sub(r"(?<=[\u4e00-\u9fff])\s+(?=[\u4e00-\u9fff0-9])", "", text) + text = re.sub(r"(?<=[0-9])\s+(?=[\u4e00-\u9fff])", "", text) + text = re.sub(r"\s*([::\--])\s*", r"\1", text) + text = re.sub(r"\s+", " ", text) + return text + + +def extract_scheduled_date_from_text(text: str, reference_date: date) -> Optional[date]: + normalized = normalize_pdf_text(text) + patterns = [ + r"(?:会议召开时间|召开时间|现场会议时间|现场会议召开时间|召开日期时间|会议时间|举行时间|活动时间)[::]?\s*(20\d{2}年\d{1,2}月\d{1,2}日)", + r"(?:会议召开时间|召开时间|现场会议时间|现场会议召开时间|召开日期时间|会议时间|举行时间|活动时间)[::]?\s*(\d{1,2}月\d{1,2}日)", + r"将?于\s*(20\d{2}年\d{1,2}月\d{1,2}日).*?(?:召开|举行)", + r"将?于\s*(\d{1,2}月\d{1,2}日).*?(?:召开|举行)", + ] + for pattern in patterns: + match = re.search(pattern, normalized) + if not match: + continue + token = match.group(1) + if "年" in token: + parsed = datetime.strptime(token, "%Y年%m月%d日").date() + else: + parsed = datetime.strptime(f"{reference_date.year}年{token}", "%Y年%m月%d日").date() + if parsed < reference_date - timedelta(days=7): + parsed = datetime.strptime(f"{reference_date.year + 1}年{token}", "%Y年%m月%d日").date() + return parsed + return None + + +def parse_chinese_date_token(token: str, reference_date: date) -> Optional[date]: + token = token.strip() + if not token: + return None + try: + if "年" in token: + return datetime.strptime(token, "%Y年%m月%d日").date() + parsed = datetime.strptime(f"{reference_date.year}年{token}", "%Y年%m月%d日").date() + if parsed < reference_date - timedelta(days=7): + return datetime.strptime(f"{reference_date.year + 1}年{token}", "%Y年%m月%d日").date() + return parsed + except ValueError: + return None + + +def extract_scheduled_window_from_text(text: str, reference_date: date) -> Tuple[Optional[date], Optional[date]]: + normalized = normalize_pdf_text(text) + patterns = [ + r"(?:实施期间|减持期间|增持期间|计划期间|实施期限|减持计划实施期间|增持计划实施期间)[::]?\s*(20\d{2}年\d{1,2}月\d{1,2}日|\d{1,2}月\d{1,2}日)\s*(?:至|-|-|—)\s*(20\d{2}年\d{1,2}月\d{1,2}日|\d{1,2}月\d{1,2}日)", + r"(?:期限届满日|截止日期|截止时间)[::]?\s*(20\d{2}年\d{1,2}月\d{1,2}日|\d{1,2}月\d{1,2}日)", + ] + for idx, pattern in enumerate(patterns): + match = re.search(pattern, normalized) + if not match: + continue + if idx == 0: + start_token, end_token = match.group(1), match.group(2) + return parse_chinese_date_token(start_token, reference_date), parse_chinese_date_token(end_token, reference_date) + end_date = parse_chinese_date_token(match.group(1), reference_date) + return reference_date, end_date + return None, None + + +def build_company_lookup(stock_pool: List[Any], instrument_meta: List[Any]) -> Dict[str, str]: + lookup: Dict[str, str] = {} + for collection in (stock_pool, instrument_meta): + for item in collection: + if isinstance(item, str): + lookup.setdefault(item, item) + continue + if not isinstance(item, dict): + continue + ticker = normalize_ticker(item) + if not ticker: + continue + company = normalize_name(item) or ticker + lookup[ticker] = company + return lookup + + +def resolve_company_name(ticker: str, record: Dict[str, Any], lookup: Dict[str, str]) -> str: + return lookup.get(ticker) or normalize_name(record) or ticker or "未知公司" + + +def parse_quarter(value: Any) -> Optional[Tuple[int, int]]: + if value in (None, ""): + return None + + text = str(value).strip() + match = re.match(r"^(\d{4})[qQ]([1-4])$", text) + if match: + return int(match.group(1)), int(match.group(2)) + + as_date = parse_iso_date(text) + if not as_date: + return None + + month_to_quarter = {3: 1, 6: 2, 9: 3, 12: 4} + quarter = month_to_quarter.get(as_date.month) + if quarter is None: + return None + return as_date.year, quarter + + +def make_event_id(*parts: str) -> str: + cleaned = [re.sub(r"[^A-Za-z0-9_.-]+", "_", part or "na") for part in parts] + return "__".join(cleaned) + + +def build_event( + *, + ticker: str, + company: str, + event_type: str, + event_title: str, + event_date: Optional[date] = None, + date_type: str, + window_start: Optional[date] = None, + window_end: Optional[date] = None, + impact_level: str, + source: str, + confidence: int, + evidence: str, + notes: str, + origin: str, + disclosure_date: Optional[date] = None, + source_link: str = "", +) -> Event: + anchor = event_date.isoformat() if event_date else (window_start.isoformat() if window_start else "unknown") + return Event( + event_id=make_event_id(ticker, event_type, event_title, anchor), + ticker=ticker, + company=company, + event_type=event_type, + event_title=event_title, + event_date=event_date, + date_type=date_type, + window_start=window_start, + window_end=window_end, + impact_level=impact_level, + source=source, + confidence=confidence, + evidence=evidence, + notes=notes, + origin=origin, + disclosure_date=disclosure_date, + source_link=source_link, + ) + + +def load_raw_inputs(data_dir: Path) -> Dict[str, List[Any]]: + file_map = { + "stock_pool": "stock_pool.json", + "earnings_express_raw": "earnings_express_raw.json", + "latest_financial_raw": "latest_financial_raw.json", + "dividend_raw": "dividend_raw.json", + "instrument_meta": "instrument_meta.json", + "announcement_raw": "announcement_raw.json", + "web_search_events": "web_search_events.json", + } + loaded: Dict[str, List[Any]] = {} + for key, filename in file_map.items(): + payload = read_json_file(data_dir / filename) + loaded[key] = extract_records(payload) + return loaded + + +def quarter_sort_key(value: Optional[Tuple[int, int]]) -> Tuple[int, int]: + if not value: + return (-1, -1) + return value + + +def quarter_label(year: int, quarter: int) -> str: + return f"{year}Q{quarter}" if quarter != 4 else f"{year}年报" + + +def statutory_deadline_for_quarter(year: int, quarter: int) -> date: + if quarter == 1: + return date(year, 4, 30) + if quarter == 2: + return date(year, 8, 31) + if quarter == 3: + return date(year, 10, 31) + return date(year + 1, 4, 30) + + +def build_latest_financial_lookup(records: List[Any]) -> Dict[str, Tuple[int, int]]: + latest: Dict[str, Tuple[int, int]] = {} + for item in records: + if not isinstance(item, dict): + continue + ticker = normalize_ticker(item) + if not ticker: + continue + quarter_info = parse_quarter(pick_first(item, ("quarter", "report_period", "report_quarter"))) + if not quarter_info: + continue + current = latest.get(ticker) + if current is None or quarter_sort_key(quarter_info) > quarter_sort_key(current): + latest[ticker] = quarter_info + return latest + + +def normalize_earnings_events( + express_records: List[Any], + latest_financial_records: List[Any], + lookup: Dict[str, str], + start_dt: date, +) -> List[Event]: + events: List[Event] = [] + latest_financial_lookup = build_latest_financial_lookup(latest_financial_records) + lookback_start = start_dt - timedelta(days=30) + + for item in express_records: + if not isinstance(item, dict): + continue + ticker = normalize_ticker(item) + if not ticker: + continue + company = resolve_company_name(ticker, item, lookup) + info_date = parse_iso_date(item.get("info_date")) + if not info_date or not (lookback_start <= info_date <= start_dt): + continue + + quarter_info = parse_quarter(item.get("end_date")) + if not quarter_info: + continue + + latest_quarter = latest_financial_lookup.get(ticker) + if latest_quarter is not None and quarter_sort_key(latest_quarter) >= quarter_sort_key(quarter_info): + continue + + year, quarter = quarter_info + express_label = quarter_label(year, quarter) + revenue = item.get("operating_revenue") + yoy = item.get("operating_revenue_yoy") + evidence_bits = [f"业绩快报公告日 {info_date.isoformat()}", f"对应报告期 {express_label}"] + if revenue not in (None, ""): + evidence_bits.append(f"营业收入 {revenue}") + if yoy not in (None, ""): + evidence_bits.append(f"营收同比 {yoy}%") + if latest_quarter: + evidence_bits.append(f"当前正式财报最新期为 {quarter_label(*latest_quarter)}") + else: + evidence_bits.append("当前未识别到正式财报期") + + events.append( + build_event( + ticker=ticker, + company=company, + event_type="财报", + event_title=f"{express_label}业绩快报后续正式财报待披露", + date_type="estimated_window", + window_start=info_date + timedelta(days=1), + window_end=statutory_deadline_for_quarter(year, quarter), + impact_level="H", + source="RQData", + confidence=4, + evidence=";".join(evidence_bits), + notes="近1个月已披露业绩快报,但正式财报尚未覆盖同一报告期,因此仅保留法定披露窗口供继续跟踪", + origin="financial_express", + disclosure_date=info_date, + ) + ) + + return events + + +def dividend_amount_text(record: Dict[str, Any]) -> str: + fields = ( + "dividend_cash_before_tax", + "cash_dividend_per_share", + "dividend_per_share", + "cash_before_tax", + ) + value = pick_first(record, fields) + if value in (None, ""): + return "金额待确认" + try: + return f"每股现金分红 {float(value):.4f} 元" + except (TypeError, ValueError): + return f"分红金额 {value}" + + +def normalize_dividend_events(records: List[Any], lookup: Dict[str, str]) -> List[Event]: + events: List[Event] = [] + + for item in records: + if not isinstance(item, dict): + continue + ticker = normalize_ticker(item) + if not ticker: + continue + company = resolve_company_name(ticker, item, lookup) + amount_text = dividend_amount_text(item) + ex_dividend_date = parse_iso_date(item.get("ex_dividend_date")) + if not ex_dividend_date: + continue + + declaration_date = parse_iso_date( + pick_first(item, ("declaration_announcement_date", "announcement_date")) + ) + record_date = parse_iso_date( + pick_first(item, ("book_closure_date", "record_date")) + ) + payable_date = parse_iso_date( + pick_first(item, ("payable_date", "payment_date")) + ) + + context_bits = [amount_text] + quarter = item.get("quarter") + if quarter: + context_bits.append(f"对应报告期 {quarter}") + if declaration_date: + context_bits.append(f"分红公告日 {declaration_date.isoformat()}") + if record_date: + context_bits.append(f"登记相关日期 {record_date.isoformat()}") + if payable_date: + context_bits.append(f"派息日 {payable_date.isoformat()}") + + evidence = ";".join(context_bits) + events.append( + build_event( + ticker=ticker, + company=company, + event_type="分红", + event_title="除权除息日", + event_date=ex_dividend_date, + date_type="exact", + impact_level="M", + source="RQData", + confidence=5, + evidence=evidence, + notes="分红数据按历史回溯查询,但只有除权除息日落入观察窗口时才计入日历", + origin="dividend", + disclosure_date=declaration_date, + ) + ) + + return events + + +def announcement_event_spec(title: str, info_type: str) -> Optional[Tuple[str, str, str, str]]: + normalized = (title or "").strip() + if not normalized: + return None + + if "年度报告" in normalized or "年年度报告" in normalized: + return ("财报", "年度报告公告", "H", "关注年报核心财务表现及分红方案") + if "第一季度报告" in normalized or "一季度报告" in normalized: + return ("财报", "一季度报告公告", "H", "关注一季报增速与全年指引") + if "半年度报告" in normalized or "中期报告" in normalized: + return ("财报", "半年度报告公告", "H", "关注中报表现与经营趋势") + if "第三季度报告" in normalized or "三季度报告" in normalized: + return ("财报", "三季度报告公告", "H", "关注三季报边际变化") + if "业绩预告" in normalized: + return ("财报", "业绩预告公告", "H", "关注预告区间与市场预期差") + if "业绩快报" in normalized: + return ("财报", "业绩快报公告", "H", "关注快报口径与正式财报差异") + if "利润分配" in normalized or "权益分派" in normalized: + return ("分红", "利润分配相关公告", "M", "关注分红方案、股权登记日和实施安排") + if "除权除息" in normalized: + return ("分红", "除权除息公告", "M", "关注除权除息和市场交易影响") + if "股权登记日" in normalized: + return ("分红", "股权登记相关公告", "M", "关注登记日和持有人资格") + if "业绩说明会" in normalized: + return ("投资者交流", "业绩说明会公告", "M", "关注交流时点、管理层表述与市场预期差") + if "股东大会" in normalized and "表决结果" not in normalized and "决议" not in normalized: + return ("股东大会", "股东大会相关公告", "M", "关注召开通知、审议议案及表决结果") + if "股东大会" in normalized and ("表决结果" in normalized or "决议" in normalized): + return ("股东大会", "股东大会结果公告", "M", "关注会议结果及其对后续治理事项的影响") + if "董事会" in normalized and "决议" in normalized: + return ("公司治理", "董事会决议公告", "M", "关注董事会决议是否引出后续催化剂") + if "监事会" in normalized and "决议" in normalized: + return ("公司治理", "监事会决议公告", "L", "关注监事会决议与治理层表态") + if "回购" in normalized: + return ("资本运作", "股份回购相关公告", "M", "关注回购节奏、规模与资本配置意图") + if "增持" in normalized or "减持" in normalized: + return ("资本运作", "股东增减持相关公告", "M", "关注股东行为及其对市场预期的影响") + if "重大资产购买" in normalized or "重大资产重组" in normalized or "收购" in normalized: + return ("资本运作", "并购重组相关公告", "H", "关注交易推进、审批和估值影响") + if info_type == "定期报告": + return ("财报", "定期报告公告", "H", "关注定期报告核心披露内容") + return None + + +def announcement_lookback_start(start_dt: date) -> date: + return start_dt - timedelta(days=120) + + +def recent_disclosure_start(start_dt: date) -> date: + return start_dt - timedelta(days=45) + + +def needs_future_date_extraction(title: str, event_type: str) -> bool: + normalized = (title or "").strip() + if event_type == "投资者交流" and ("召开" in normalized or "举行" in normalized) and "业绩说明会" in normalized: + return True + if event_type == "股东大会" and any(token in normalized for token in ("召开", "通知", "会议材料")): + return True + if event_type == "资本运作" and any(token in normalized for token in ("增持", "减持")) and any( + token in normalized for token in ("计划", "进展", "期间", "期限", "届满", "完成", "结果") + ): + return True + return False + + +def extract_announced_event_date(item: Dict[str, Any], event_type: str, cache: Dict[str, Optional[date]]) -> Optional[date]: + link = str(item.get("announcement_link") or "") + if not link: + return None + if link in cache: + return cache[link] + + if str(item.get("file_type") or "").upper() != "PDF": + cache[link] = None + return None + + reference_date = parse_iso_date(pick_first(item, ("info_date", "create_tm"))) + if not reference_date: + cache[link] = None + return None + + pdf_bytes, fetch_status = fetch_pdf_bytes(link) + if not pdf_bytes or fetch_status != "ok": + cache[link] = None + return None + + try: + text = extract_pdf_text(pdf_bytes) + except Exception: + cache[link] = None + return None + + event_date = extract_scheduled_date_from_text(text, reference_date) + cache[link] = event_date + return event_date + + +def normalize_announcement_events(records: List[Any], lookup: Dict[str, str]) -> List[Event]: + events: List[Event] = [] + extracted_date_cache: Dict[str, Optional[date]] = {} + + for item in records: + if not isinstance(item, dict): + continue + ticker = normalize_ticker(item) + if not ticker: + continue + company = resolve_company_name(ticker, item, lookup) + title = str(item.get("title") or "").strip() + info_type = str(item.get("info_type") or "").strip() + spec = announcement_event_spec(title, info_type) + if not spec: + continue + + event_type, event_title, impact_level, notes = spec + disclosure_date = parse_iso_date(pick_first(item, ("info_date", "create_tm"))) + if not disclosure_date: + continue + event_date = disclosure_date + date_type = "exact" + window_start: Optional[date] = None + window_end: Optional[date] = None + if needs_future_date_extraction(title, event_type): + extracted_date = extract_announced_event_date(item, event_type, extracted_date_cache) + if extracted_date: + event_date = extracted_date + elif event_type == "资本运作": + link = str(item.get("announcement_link") or "") + pdf_bytes, fetch_status = fetch_pdf_bytes(link) + if pdf_bytes and fetch_status == "ok": + try: + text = extract_pdf_text(pdf_bytes) + except Exception: + text = "" + if text: + window_start, window_end = extract_scheduled_window_from_text(text, disclosure_date) + if window_end: + date_type = "estimated_window" + event_date = None + window_start = window_start or disclosure_date + confidence = 4 + else: + confidence = 5 + else: + confidence = 5 + else: + confidence = 5 + else: + confidence = 5 + else: + confidence = 5 + + media = str(item.get("media") or "未知媒体") + evidence = f"公告标题:{title};公告类型:{info_type or '未分类'};来源媒体:{media}" + events.append( + build_event( + ticker=ticker, + company=company, + event_type=event_type, + event_title=event_title, + event_date=event_date, + date_type=date_type, + window_start=window_start, + window_end=window_end, + impact_level=impact_level, + source="RQData", + confidence=confidence, + evidence=evidence, + notes=notes, + origin="announcement", + disclosure_date=disclosure_date, + source_link=str(item.get("announcement_link") or ""), + ) + ) + + return events + + +def normalize_web_source_type(value: Any) -> str: + text = str(value or "").strip().lower() + aliases = { + "official_site": "official", + "official_website": "official", + "gov": "government", + "media": "general_news", + "news": "general_news", + } + normalized = aliases.get(text, text) + if normalized not in WEB_SOURCE_TYPE_LABELS: + raise ValueError(f"web_search source_type 不受支持: {value}") + return normalized + + +def parse_confidence(value: Any) -> int: + try: + confidence = int(value) + except (TypeError, ValueError) as exc: + raise ValueError(f"置信度必须是 1-5 的整数: {value}") from exc + if not 1 <= confidence <= 5: + raise ValueError(f"置信度必须在 1-5 之间: {value}") + return confidence + + +def normalize_web_search_events(records: List[Any], lookup: Dict[str, str]) -> List[Event]: + events: List[Event] = [] + + for index, item in enumerate(records): + if not isinstance(item, dict): + continue + + required_fields = [ + "query", + "source_name", + "source_type", + "title", + "url", + "published_at", + "retrieved_at", + "summary", + "why_relevant", + "confidence", + "event_scope", + "scope_name", + "event_title", + "date_type", + "impact_level", + ] + missing = [field for field in required_fields if item.get(field) in (None, "")] + if missing: + raise ValueError(f"web_search_events.json 第 {index + 1} 条缺少字段: {', '.join(missing)}") + + source_type = normalize_web_source_type(item.get("source_type")) + source_name = str(item.get("source_name") or "").strip() + query = str(item.get("query") or "").strip() + title = str(item.get("title") or "").strip() + url = str(item.get("url") or "").strip() + summary = str(item.get("summary") or "").strip() + why_relevant = str(item.get("why_relevant") or "").strip() + scope = str(item.get("event_scope") or "").strip().lower() + scope_name = str(item.get("scope_name") or "").strip() + event_title = str(item.get("event_title") or title).strip() + event_type = str(item.get("event_type") or ("宏观" if scope == "macro" else "行业")).strip() + date_type = str(item.get("date_type") or "").strip().lower() + impact_level = str(item.get("impact_level") or "").strip().upper() + confidence = parse_confidence(item.get("confidence")) + + if scope not in {"macro", "industry"}: + raise ValueError(f"web_search event_scope 仅支持 macro / industry: {scope or '空'}") + if impact_level not in {"H", "M", "L"}: + raise ValueError(f"web_search impact_level 必须为 H/M/L: {impact_level or '空'}") + + published_at = parse_iso_date(item.get("published_at")) + retrieved_at = parse_iso_date(item.get("retrieved_at")) + if not published_at or not retrieved_at: + raise ValueError(f"web_search_events.json 第 {index + 1} 条的 published_at / retrieved_at 非法") + if published_at > retrieved_at: + raise ValueError(f"web_search_events.json 第 {index + 1} 条的 published_at 晚于 retrieved_at") + + max_confidence = WEB_SOURCE_TYPE_MAX_CONFIDENCE[source_type] + event_date: Optional[date] = None + window_start: Optional[date] = None + window_end: Optional[date] = None + if date_type == "exact": + event_date = parse_iso_date(item.get("event_date")) + if not event_date: + raise ValueError(f"web_search_events.json 第 {index + 1} 条缺少合法的 event_date") + elif date_type == "estimated_window": + window_start = parse_iso_date(item.get("window_start")) + window_end = parse_iso_date(item.get("window_end")) + if not window_start or not window_end: + raise ValueError(f"web_search_events.json 第 {index + 1} 条缺少合法的 window_start/window_end") + if window_end < window_start: + raise ValueError(f"web_search_events.json 第 {index + 1} 条的 window_end 早于 window_start") + max_confidence = min(max_confidence, 3) + else: + raise ValueError(f"web_search date_type 仅支持 exact / estimated_window: {date_type or '空'}") + confidence = min(confidence, max_confidence) + + ticker = normalize_ticker(item) + company = resolve_company_name(ticker, item, lookup) if ticker else scope_name + evidence = ";".join( + [ + f"检索词:{query}", + f"来源标题:{title}", + f"来源类型:{WEB_SOURCE_TYPE_LABELS[source_type]}", + f"发布时间:{published_at.isoformat()}", + f"检索时间:{retrieved_at.isoformat()}", + f"摘要:{summary}", + ] + ) + notes = why_relevant + events.append( + build_event( + ticker=ticker, + company=company, + event_type=event_type, + event_title=event_title, + event_date=event_date, + date_type=date_type, + window_start=window_start, + window_end=window_end, + impact_level=impact_level, + source=source_name, + confidence=confidence, + evidence=evidence, + notes=notes, + origin="web_search", + disclosure_date=published_at, + source_link=url, + ) + ) + + return events + + +def deduplicate_events(events: Iterable[Event]) -> List[Event]: + deduped: Dict[Tuple[str, str, str, str], Event] = {} + for event in events: + anchor = event.display_date() + key = (event.ticker, event.event_type, event.event_title, anchor) + existing = deduped.get(key) + if existing is None or event.confidence > existing.confidence: + deduped[key] = event + return sorted(deduped.values(), key=lambda item: item.sort_key()) + + +def is_future_calendar_event(event: Event, start_dt: date, end_dt: date) -> bool: + if event.date_type == "exact" and event.event_date: + return start_dt <= event.event_date <= end_dt + if event.window_start and event.window_end: + return not (event.window_end < start_dt or event.window_start > end_dt) + return False + + +def is_recent_disclosed_catalyst(event: Event, start_dt: date) -> bool: + disclosure_date = event.disclosure_date or event.event_date + if disclosure_date is None: + return False + if not (recent_disclosure_start(start_dt) <= disclosure_date < start_dt): + return False + if event.origin == "financial_express": + return False + if event.origin != "announcement": + return False + if event.date_type == "exact" and event.event_date and event.event_date >= start_dt: + return False + return event.impact_level in {"H", "M"} + + +def filter_future_calendar_events(events: Sequence[Event], start_dt: date, end_dt: date) -> List[Event]: + return [event for event in events if is_future_calendar_event(event, start_dt, end_dt)] + + +def filter_recent_disclosed_events(events: Sequence[Event], start_dt: date) -> List[Event]: + filtered = [event for event in events if is_recent_disclosed_catalyst(event, start_dt)] + return sorted( + filtered, + key=lambda item: (score_recent_disclosed_event(item, start_dt), (item.disclosure_date or item.event_date or date.min), item.company), + reverse=True, + )[:40] + + +def score_recent_disclosed_event(event: Event, start_dt: date) -> int: + score = 0 + disclosure_date = event.disclosure_date or event.event_date + if disclosure_date: + score += max(0, 40 - (start_dt - disclosure_date).days) + if event.impact_level == "H": + score += 60 + elif event.impact_level == "M": + score += 25 + if event.origin == "financial_express": + score += 30 + if event.event_type == "财报": + score += 35 + elif event.event_type == "分红": + score += 20 + elif event.event_type == "投资者交流": + score += 18 + elif event.event_type == "资本运作" and "增减持" in event.event_title: + score += 15 + elif event.event_type == "资本运作" and "并购" in event.event_title: + score += 22 + elif event.event_type == "公司治理": + score -= 15 + return score + + +def window_display_in_range(event: Event, start_dt: date, end_dt: Optional[date] = None) -> str: + if event.date_type == "exact": + return event.display_date() + if not event.window_end: + return event.display_date() + clipped_start = max(start_dt, event.window_start or start_dt) + clipped_end = min(end_dt, event.window_end) if end_dt else event.window_end + return f"预计 {clipped_start.isoformat()} 至 {clipped_end.isoformat()}" + + +def source_confidence_label(event: Event) -> str: + return f"{event.source} / {event.confidence}" + + +def summarize_source_mix(events: Sequence[Event], *, limit: int = 6) -> str: + labels: List[str] = [] + for event in events: + label = f"{event.source}(置信度{event.confidence})" + if label not in labels: + labels.append(label) + if len(labels) >= limit: + break + if not labels: + return "暂无可展示来源" + return ";".join(labels) + + +def summarize_counter(values: Iterable[str], *, limit: int = 4) -> str: + counter = Counter(value for value in values if value) + if not counter: + return "暂无" + return "、".join(f"{name}{count}个" for name, count in counter.most_common(limit)) + + +def markdown_escape(value: Any) -> str: + text = "" if value is None else str(value) + return text.replace("|", "\\|").replace("\n", " ") + + +def render_markdown_table(headers: Sequence[str], rows: Sequence[Sequence[Any]]) -> str: + if not rows: + return "暂无数据。" + header_line = "| " + " | ".join(markdown_escape(cell) for cell in headers) + " |" + separator = "| " + " | ".join("---" for _ in headers) + " |" + body = [ + "| " + " | ".join(markdown_escape(cell) for cell in row) + " |" + for row in rows + ] + return "\n".join([header_line, separator] + body) + + +def build_calendar_table(events: Sequence[Event]) -> str: + exact_events = [event for event in events if event.date_type == "exact" and event.event_date] + if not exact_events: + return "未来 30 天内暂无已确认日期的事件。" + + grouped: Dict[str, List[Event]] = defaultdict(list) + for event in exact_events: + grouped[event.event_date.isoformat()].append(event) + + rows = [] + for day in sorted(grouped)[:20]: + bucket = sorted(grouped[day], key=lambda item: (item.impact_level != "H", item.event_type, item.company)) + type_counter = Counter(event.event_type for event in bucket) + focus = [] + for event in bucket: + label = f"{event.company} {event.event_title}" + if event.source_link: + label = markdown_link(label, event.source_link) + focus.append(label) + if len(focus) >= 3: + break + rows.append( + [ + day, + len(bucket), + sum(1 for event in bucket if event.impact_level == "H"), + "、".join(f"{name}{count}" for name, count in type_counter.most_common(3)), + ";".join(focus), + summarize_source_mix(bucket, limit=3), + ] + ) + headers = ["日期", "事件数", "高影响", "主要类型", "重点公司/事项", "来源/置信度"] + return render_markdown_table(headers, rows) + + +def build_summary( + future_events: Sequence[Event], + recent_events: Sequence[Event], + start_dt: date, + end_dt: date, + coverage_scope: str, +) -> str: + total = len(future_events) + if total == 0 and not recent_events: + return "\n".join( + [ + f"- 观察区间内未识别到事件,覆盖范围为 {coverage_scope}。", + "- 当前没有可展示的未来事件,也未识别到仍值得跟踪的近期已披露催化剂。", + "- 建议优先检查原始 JSON 是否存在、字段名是否匹配输入契约。", + ] + ) + + high_impact = [event for event in future_events if event.impact_level == "H"] + exact_dates = Counter( + event.event_date.isoformat() + for event in future_events + if event.event_date and event.date_type == "exact" + ) + most_crowded = "无精确日期事件" + if exact_dates: + event_date, count = exact_dates.most_common(1)[0] + most_crowded = f"{event_date}({count} 个事件)" + + tracked_subjects = len({event.ticker or event.company for event in future_events}) + estimated = len([event for event in future_events if event.date_type == "estimated_window"]) + future_from_prior_disclosures = sum( + 1 + for event in future_events + if event.origin == "announcement" and event.disclosure_date and event.event_date and event.disclosure_date < start_dt <= event.event_date + ) + external_events = len([event for event in future_events if event.origin == "web_search"]) + recent_type_mix = summarize_counter(event.event_type for event in recent_events) + recent_company_mix = summarize_counter(event.company for event in recent_events) + + return "\n".join( + [ + f"- 未来 {(end_dt - start_dt).days + 1} 天共识别 {total} 个事件,覆盖 {tracked_subjects} 个跟踪主体。", + f"- 其中高影响事件 {len(high_impact)} 个,主要集中在财报和关键信息披露时点。", + f"- 事件最密集日期为 {most_crowded}。", + f"- 其中 {future_from_prior_disclosures} 个确定日期事件来自更早公告的日程安排,已按实际事件日而非公告披露日纳入日历。", + f"- 另补充纳入 {external_events} 个宏观 / 行业外部催化事件,用于补足 `RQData CLI` 之外的实时信息。", + f"- 仍有 {estimated} 个事件缺少精确日期,仅能以预计窗口形式跟踪。", + f"- 另有 {len(recent_events)} 个近期已披露催化剂保留在回顾区,供判断未来 30 天潜在影响。", + f"- 近期已披露催化剂主要集中在 {recent_type_mix},涉及主体以 {recent_company_mix} 为主。", + ] + ) + + +def build_event_type_sections( + future_events: Sequence[Event], + recent_events: Sequence[Event], + start_dt: date, + end_dt: date, +) -> str: + if not future_events and not recent_events: + return "暂无事件类型明细。\n\n*数据来源:暂无可展示来源*" + + buckets: Dict[str, List[Event]] = defaultdict(list) + active_events = list(future_events) + using_recent_fallback = False + for event in future_events: + buckets[event.event_type].append(event) + if not future_events and recent_events: + using_recent_fallback = True + for event in recent_events: + buckets[event.event_type].append(event) + + sections: List[str] = [] + if using_recent_fallback: + sections.extend( + [ + "当前观察区间内缺少已确认的未来事件,以下按类型回顾近期已披露、但仍可能影响未来 30 天预期演化的催化剂。", + "", + ] + ) + active_events = list(recent_events) + ordered_types = ["财报", "分红", "股东大会", "投资者交流", "公司治理", "资本运作", "宏观", "行业"] + for event_type in ordered_types + sorted(set(buckets) - set(ordered_types)): + bucket = buckets.get(event_type) + if not bucket: + continue + bucket = sorted(bucket, key=lambda item: item.sort_key())[:8] + sections.append(f"### {event_type}") + if using_recent_fallback: + sections.append( + render_markdown_table( + ["披露日", "公司", "事项", "仍需跟踪", "来源/置信度"], + [ + [ + (event.disclosure_date or event.event_date).isoformat() if (event.disclosure_date or event.event_date) else "待确认", + event.company, + event.event_title, + event.notes, + source_confidence_label(event), + ] + for event in bucket + ], + ) + ) + sections.append("") + sections.append(f"- 类型说明:近期该类事项共保留 {len(bucket)} 个,主要用于评估未来 30 天的预期延续、股东回报兑现和二次交易催化。") + sections.append("") + sections.append(f"*数据来源:{summarize_source_mix(bucket)}*") + sections.append("") + continue + sections.append( + render_markdown_table( + ["日期", "公司", "事件", "影响", "跟踪要点", "来源/置信度"], + [ + [ + window_display_in_range(event, start_dt, end_dt), + event.company, + event.event_title, + event.impact_level, + event.notes, + source_confidence_label(event), + ] + for event in bucket + ], + ) + ) + sections.append("") + sections.append(f"*数据来源:{summarize_source_mix(bucket)}*") + sections.append("") + if using_recent_fallback: + sections.append(f"整体来源汇总:{summarize_source_mix(active_events)}。") + return "\n".join(sections).strip() + + +def build_high_impact_sections( + future_events: Sequence[Event], + recent_events: Sequence[Event], + start_dt: date, + end_dt: date, +) -> str: + high_impact = [event for event in future_events if event.impact_level == "H"] + using_recent_fallback = False + if not high_impact: + high_impact = [event for event in recent_events if event.impact_level == "H"] + using_recent_fallback = bool(high_impact) + if not high_impact: + return "当前观察区间内无高影响事件。" + + sections: List[str] = [] + if using_recent_fallback: + sections.extend( + [ + "未来窗口内缺少已确认的高影响未来事件,以下展开近期已披露、但仍可能持续影响未来 30 天预期和交易节奏的高影响催化剂。", + "", + ] + ) + for event in high_impact[:12]: + sections.append(f"### {event.company} - {event.event_title}") + if using_recent_fallback: + sections.append( + f"- 披露日:{(event.disclosure_date or event.event_date).isoformat() if (event.disclosure_date or event.event_date) else '待确认'}" + ) + else: + sections.append(f"- 日期:{window_display_in_range(event, start_dt, end_dt)}") + sections.append(f"- 类型:{event.event_type}") + sections.append(f"- 关注点:{event.notes}") + sections.append(f"- 依据:{event.evidence}") + if using_recent_fallback: + sections.append("- 持续影响:虽然事项已披露,但年报、分红方案、治理安排和资本运作仍可能在未来 30 天内继续影响预期修正和交易行为。") + if event.disclosure_date and event.disclosure_date != event.event_date: + sections.append(f"- 披露日:{event.disclosure_date.isoformat()}") + if event.source_link: + sections.append(f"- 原文:{markdown_link('公告链接', event.source_link)}") + sections.append(f"- 数据来源:{event.source},置信度{event.confidence}") + sections.append("") + return "\n".join(sections).strip() + + +def build_recent_disclosed_sections(events: Sequence[Event]) -> str: + if not events: + return "近期未识别到需要单独回顾的已披露催化剂。" + + type_mix = summarize_counter((event.event_type for event in events)) + company_mix = summarize_counter((event.company for event in events), limit=6) + rows = [] + for event in events[:16]: + rows.append( + [ + (event.disclosure_date or event.event_date).isoformat() if (event.disclosure_date or event.event_date) else "待确认", + event.company, + event.event_title, + event.notes, + source_confidence_label(event), + markdown_link("原文", event.source_link), + ] + ) + return "\n".join( + [ + "- 以下事项已在观察窗口前披露,但仍可能影响未来 30 天的交易与预期演化。", + f"- 类型分布:{type_mix}。", + f"- 重点公司分布:{company_mix}。", + "", + render_markdown_table(["披露日", "公司", "事项", "仍需跟踪", "来源/置信度", "链接"], rows), + "", + f"*数据来源:{summarize_source_mix(events)}*", + ] + ) + + +def build_action_section(future_events: Sequence[Event], recent_events: Sequence[Event], start_dt: date) -> str: + if not future_events and not recent_events: + return "当前无可执行的重点跟踪建议。" + + lines: List[str] = [] + next_week = [event for event in future_events if event.event_date and event.event_date <= start_dt + timedelta(days=7)] + if next_week: + by_company = Counter(event.company for event in next_week if event.impact_level == "H") + if by_company: + focus_names = "、".join(name for name, _count in by_company.most_common(5)) + lines.append(f"- **优先盯住未来 7 天高影响事项**:{focus_names} 在短窗口内有关键披露或会议安排,适合放入盘前重点跟踪清单。") + + combo_names = [] + future_tickers = {event.ticker for event in future_events if event.event_date and event.event_date <= start_dt + timedelta(days=14)} + recent_tickers = {event.ticker for event in recent_events} + for event in future_events: + if event.ticker in future_tickers & recent_tickers and event.company not in combo_names: + combo_names.append(event.company) + if len(combo_names) >= 5: + break + if combo_names: + lines.append(f"- **重视“近期已披露 + 即将落地”组合**:{('、'.join(combo_names))} 已有前序公告铺垫,后续事件更容易形成连续催化。") + + dividend_names = [event.company for event in future_events if event.event_type == "分红"] + if dividend_names: + deduped = [] + for name in dividend_names: + if name not in deduped: + deduped.append(name) + if len(deduped) >= 5: + break + lines.append(f"- **分红与股东回报线索**:{('、'.join(deduped))} 在窗口内有除权除息或相关安排,可结合持仓收益兑现和事件后交易节奏评估。") + + if recent_events: + recent_focus = [] + for event in recent_events: + if event.company not in recent_focus: + recent_focus.append(event.company) + if len(recent_focus) >= 5: + break + lines.append(f"- **把近期披露板作为前瞻跟踪补位**:{('、'.join(recent_focus))} 近 45 天内已有高关注公告,即使未来日历暂时缺少精确日期,也应持续跟踪市场对业绩、分红和治理事项的二次反应。") + + recent_type_mix = summarize_counter((event.event_type for event in recent_events), limit=3) + lines.append(f"- **优先围绕高频类型做盘前更新**:当前近期披露事项以 {recent_type_mix} 为主,适合在后续催化确认前持续维护重点名单和观察字段。") + + if not lines: + lines.append("- 当前更适合维持事件跟踪而非提前做重仓博弈,等待更明确的高影响日程或经营披露进一步落地。") + + lines.extend(["", f"*数据来源:{summarize_source_mix([*future_events, *recent_events])}*"]) + return "\n".join(lines) + + +def build_estimated_event_sections(events: Sequence[Event], start_dt: date, end_dt: date) -> str: + estimated = [event for event in events if event.date_type == "estimated_window"] + if not estimated: + return "当前观察区间内无日期不确定事件。" + + lines = [ + "以下事件缺少精确日期,仅展示预计窗口,不代表正式公告日。", + "", + render_markdown_table( + ["预计窗口", "公司", "代码", "事件", "影响", "说明", "来源/置信度"], + [ + [ + window_display_in_range(event, start_dt, end_dt), + event.company, + event.ticker, + event.event_title, + event.impact_level, + event.evidence, + source_confidence_label(event), + ] + for event in estimated[:12] + ], + ), + "", + f"*数据来源:{summarize_source_mix(estimated)}*", + ] + return "\n".join(lines) + + +def build_appendix( + report_date: str, + future_events: Sequence[Event], + recent_events: Sequence[Event], +) -> str: + all_events = [*future_events, *recent_events] + rqdata_count = sum(1 for event in all_events if event.origin != "web_search") + web_count = sum(1 for event in all_events if event.origin == "web_search") + return "\n".join( + [ + f"- 报告日期:{report_date}", + f"- 未来日历事件数:{len(future_events)};近期已披露催化剂数:{len(recent_events)}。", + "- 公告口径:若过去公告已明确未来会议或活动日期,报告按实际事件日纳入未来日历,并保留原文链接。", + "- 预计窗口口径:仅在缺少精确日期时展示法定或规则化窗口,不将预计窗口伪装成确定日期。", + f"- 数据来源构成:RQData 事件 {rqdata_count} 个;web_search 补充事件 {web_count} 个。", + f"- 主要来源汇总:{summarize_source_mix(all_events)}。", + f"- 近期披露类型分布:{summarize_counter((event.event_type for event in recent_events))}。", + ] + ) + + +def load_template(template_path: Path) -> str: + with template_path.open("r", encoding="utf-8") as fh: + return fh.read() + + +def replace_tokens(template_text: str, tokens: Dict[str, str]) -> str: + rendered = template_text + for key, value in tokens.items(): + rendered = rendered.replace(f"[[{key}]]", value) + + leftovers = sorted(set(TOKEN_RE.findall(rendered))) + if leftovers: + raise ValueError(f"模板仍有未替换占位符: {', '.join(leftovers)}") + return rendered + + +def validate_rendered_report(report_text: str) -> None: + for heading in REQUIRED_HEADINGS: + if heading not in report_text: + raise ValueError(f"报告缺少必需章节: {heading}") + if "数据来源:" not in report_text: + raise ValueError("报告缺少数据来源标注") + + +def chinese_char_count(text: str) -> int: + return len(re.findall(r"[\u4e00-\u9fff]", text)) + + +def save_markdown(output_path: Path, content: str) -> Path: + output_path.parent.mkdir(parents=True, exist_ok=True) + with output_path.open("w", encoding="utf-8") as fh: + fh.write(content) + return output_path + + +def locate_renderer_script() -> Optional[Path]: + env_path = os.environ.get("RQ_REPORT_RENDERER") + candidates = [Path(env_path).expanduser()] if env_path else [] + repo_renderer = Path(__file__).resolve().parents[2] / "report-renderer" / "scripts" / "render_report.py" + candidates.append(repo_renderer) + + for candidate in candidates: + if candidate.exists(): + return candidate + return None + + +def render_html_if_needed(markdown_path: Path, no_render: bool) -> Optional[Path]: + if no_render: + return None + + renderer = locate_renderer_script() + if not renderer: + print("警告: 未找到 rq-report-renderer,跳过 HTML 渲染。", file=sys.stderr) + return None + + html_path = markdown_path.with_suffix(".html") + try: + subprocess.run( + ["python3", str(renderer), str(markdown_path), str(html_path)], + check=True, + capture_output=True, + text=True, + ) + return html_path + except subprocess.CalledProcessError as exc: + print("警告: HTML 渲染失败,保留 Markdown 输出。", file=sys.stderr) + if exc.stderr: + print(exc.stderr, file=sys.stderr) + return None + + +def infer_coverage_scope(stock_pool: List[Any], events: Sequence[Event]) -> str: + tickers = {event.ticker for event in events if event.ticker} + external_scopes = {event.company for event in events if event.origin == "web_search" and event.company} + if stock_pool: + pool_size = 0 + for item in stock_pool: + if isinstance(item, str): + pool_size += 1 + elif isinstance(item, dict) and normalize_ticker(item): + pool_size += 1 + if pool_size: + parts = [f"股票池 {pool_size} 只证券,窗口内识别到 {len(tickers)} 只证券存在事件"] + if external_scopes: + parts.append(f"并补充 {len(external_scopes)} 个宏观 / 行业主题催化") + return ",".join(parts) + if tickers: + return f"窗口内共 {len(tickers)} 只证券存在事件" + if external_scopes: + return f"窗口内共 {len(external_scopes)} 个宏观 / 行业主题存在事件" + return "未识别到有效覆盖证券" + + +def default_output_path(data_dir: Path, start_dt: date, end_dt: date) -> Path: + filename = f"催化剂日历_{start_dt.isoformat()}_{end_dt.isoformat()}.md" + return data_dir / filename + + +def main() -> None: + args = parse_args() + start_dt = parse_iso_date(args.start_date) + end_dt = parse_iso_date(args.end_date) + if not start_dt or not end_dt: + raise SystemExit("错误: --start-date 和 --end-date 必须是 YYYY-MM-DD") + if end_dt < start_dt: + raise SystemExit("错误: end-date 不能早于 start-date") + + data_dir = Path(args.data_dir).expanduser().resolve() + template_path = Path(args.template).expanduser().resolve() + output_path = ( + Path(args.output).expanduser().resolve() + if args.output + else default_output_path(data_dir, start_dt, end_dt) + ) + + raw_inputs = load_raw_inputs(data_dir) + company_lookup = build_company_lookup(raw_inputs["stock_pool"], raw_inputs["instrument_meta"]) + + events = deduplicate_events( + [ + *normalize_earnings_events( + raw_inputs["earnings_express_raw"], + raw_inputs["latest_financial_raw"], + company_lookup, + start_dt, + ), + *normalize_dividend_events(raw_inputs["dividend_raw"], company_lookup), + *normalize_announcement_events(raw_inputs["announcement_raw"], company_lookup), + *normalize_web_search_events(raw_inputs["web_search_events"], company_lookup), + ] + ) + future_events = filter_future_calendar_events(events, start_dt, end_dt) + recent_events = filter_recent_disclosed_events(events, start_dt) + + coverage_scope = infer_coverage_scope(raw_inputs["stock_pool"], future_events or recent_events) + tokens = { + "REPORT_DATE": args.report_date, + "START_DATE": start_dt.isoformat(), + "END_DATE": end_dt.isoformat(), + "COVERAGE_SCOPE": coverage_scope, + "EXEC_SUMMARY": build_summary(future_events, recent_events, start_dt, end_dt, coverage_scope), + "CALENDAR_TABLE": build_calendar_table(future_events), + "EVENT_TYPE_SECTIONS": build_event_type_sections(future_events, recent_events, start_dt, end_dt), + "HIGH_IMPACT_SECTIONS": build_high_impact_sections(future_events, recent_events, start_dt, end_dt), + "RECENT_DISCLOSED_SECTIONS": build_recent_disclosed_sections(recent_events), + "ACTION_SECTION": build_action_section(future_events, recent_events, start_dt), + "ESTIMATED_EVENT_SECTIONS": build_estimated_event_sections(future_events, start_dt, end_dt), + "APPENDIX": build_appendix(args.report_date, future_events, recent_events), + } + + report_text = replace_tokens(load_template(template_path), tokens) + validate_rendered_report(report_text) + + markdown_path = save_markdown(output_path, report_text) + html_path = render_html_if_needed(markdown_path, args.no_render) + + print(f"Markdown 报告: {markdown_path}") + if html_path: + print(f"HTML 报告: {html_path}") + + char_count = chinese_char_count(report_text) + if char_count < 1200: + print( + f"警告: 当前报告中文字符数约 {char_count},低于目标长度,通常意味着未来事件样本不足或输入数据不足。", + file=sys.stderr, + ) + + +if __name__ == "__main__": + main() diff --git a/.cursor/skills/earnings-analysis/SKILL.md b/.cursor/skills/earnings-analysis/SKILL.md new file mode 100644 index 000000000..56adab219 --- /dev/null +++ b/.cursor/skills/earnings-analysis/SKILL.md @@ -0,0 +1,503 @@ +--- +name: rq-earnings-analysis +description: | + 创建模板驱动的财报分析报告,在财报披露后基于真实财务数据、市场预期、公告原文链接、卖方研报和股价反应完成结构化复盘。 + `RQData CLI` 负责财务、估值、价格、公告和一致预期主数据;若需要补充财报电话会、管理层动态、行业或政策语境,可额外使用 `web_search` 获取实时信息,并先落为结构化 JSON,再统一生成 Markdown 与 HTML 报告。 + + 务必使用此技能当用户: + - 明确请求财报分析、季度业绩点评、earnings update、post-earnings report + - 想知道财报发布后是超预期、符合预期还是低于预期 + - 需要结合财务数据、市场预期和股价反馈做复盘 + + 不适用场景: + - 财报前瞻 -> earnings-preview + - 首次覆盖 -> initiating-coverage + - 只要一句话快评 +compatibility: "Requires rqdata CLI, Bash, python3. Report structure is managed by earnings-analysis/assets/template.md." +--- + +# RQ 股票研究 - 财报分析 + +## 核心原则 + +- 所有内容必须遵循三阶段流程:数据采集 -> 报告生成 -> HTML 渲染 +- `assets/template.md` 是唯一报告模板来源;Python 只做数据归一化、指标计算、占位符填充和结构校验 +- Python 只输出结构化 facts / tables / signal rules,不在代码里硬写观点性结论 +- `RQData CLI` 是财务、估值、价格、公告和一致预期的主源;`web_search` 只补充 CLI 无法直接提供的实时外部语境 +- 最新财报季度必须从真实 `financial` 数据中按 `info_date <= report-date` 自动识别,不能硬写季度 +- “超预期/低于预期”判断必须明确口径,优先使用财报前一致预期和高度相关的卖方点评 +- 研报必须做相关性过滤;行业周报、策略报告不能冒充公司点评 +- 缺失数据时必须明确写“无数据 / 未提供”,不能留空 + +## 数据源分工 + +### `RQData CLI` 负责 + +- 公司信息、行业归属、历史财务、ROE、估值与股息率 +- 财报前后价格反应、成交额变化、基准超额收益 +- 一致预期、目标价、卖方研报 +- 正式公告、业绩说明会、财报原文链接 + +### `web_search` 负责 + +- 财报电话会安排、管理层最新外部表态 +- 财报后行业或政策语境 +- `RQData CLI` 未直接提供、但会影响财报解读的实时背景信息 + +### `web_search` 禁止替代的内容 + +- 财务数字、估值指标、价格数据 +- 正式公告、财报披露日、分红、交易所披露 +- 一致预期和结构化卖方预测 + +## `web_search` 使用规则 + +详细字段、来源等级、落盘示例和 fallback 规则见 [references/web_search.md](references/web_search.md)。 + +允许补充的内容: + +- 管理层动态、财报电话会、IR 活动安排 +- 行业景气、政策变化、监管动态 +- 财报后几天内的重要公司新闻或权威媒体解读 + +落盘要求: + +- 所有 `web_search` 结果必须先写入 `web_search_findings.json` +- 只写结构化记录,不把自然语言笔记直接塞进报告 +- 每条记录都必须保留来源、链接、发布时间、检索时间、相关性说明和置信度 + +## 硬性规则 + +以下任一条违反,视为输出失败: + +- `[MUST-1]` 所有财务数字必须来自 `RQData CLI` +- `[MUST-2]` `web_search` 不得替代财报、估值、价格、公告和一致预期主源 +- `[MUST-3]` 金额类数据必须按可读口径展示;原始“元”金额在正文中应换算为“亿元” +- `[MUST-4]` 必须保留正式公告原文链接;若原文提取失败,也必须保留失败状态和原文链接 +- `[MUST-5]` “超预期 / 符合预期 / 低于预期”必须给出口径,不能空喊观点 +- `[MUST-6]` 每个关键数据点或关键结论都要标 `数据来源:XXX,置信度X` +- `[MUST-7]` 图表若未生成,必须由等价表格或趋势表完成降级,不能让关键章节失真 +- `[MUST-8]` 低置信度外部信息不得改写核心财务判断 +- `[MUST-9]` 最终输出必须严格来自模板,不得在脚本中自由拼写整篇报告 + +## 确信度评级 + +- `5`:RQData CLI、交易所公告、上市公司官网、官方监管披露 +- `4`:政府 / 监管 / 行业协会 / 官方机构、权威财经媒体 +- `3`:一般媒体或二手整理,但来源清晰且与其他来源一致 +- `2`:单一来源、细节不完整、时点未充分验证 +- `1`:推断、估算窗口、未验证信息 + +使用规则: + +- 混合结论的置信度取关键来源中的最低值 +- 推断或估计信息不得标成高置信度 +- 低置信度信息只能作为补充背景,不能成为财报结论唯一依据 + +## 图表 / 图片需求 + +当前脚本以趋势表降级交付,但本 skill 仍必须定义最终报告达标所需的图表需求。 + +- 图表名称:收入与净利润趋势图 +- 图表目的:展示近 8 个季度累计与单季收入、净利润变化 +- 使用的数据文件:`historical_financials.json` +- 关键字段:`quarter`、`revenue`、`net_profit` +- 建议图表类型:双轴折线或柱线组合 +- 回答问题:增长趋势是否延续,单季拐点是否出现 +- 放置位置:`## 财报概览` +- 若图表缺失:保留累计趋势表和单季趋势表 + +- 图表名称:盈利能力与质量图 +- 图表目的:展示毛利率、ROE、现金转化率和资产负债率变化 +- 使用的数据文件:`historical_financials.json`、`roe_history.json` +- 关键字段:`gross_profit`、`revenue`、`return_on_equity_weighted_average`、`cash_from_operating_activities`、`total_assets`、`total_liabilities` +- 建议图表类型:折线图或分组柱状图 +- 回答问题:财务质量是在改善还是恶化 +- 放置位置:`## 财务质量与资产负债表` +- 若图表缺失:保留财务质量表和 ROE 取值表 + +- 图表名称:预期修正与价格反应图 +- 图表目的:展示财报前后预期变化与 1D / 3D / 5D 股价反馈 +- 使用的数据文件:`consensus.json`、`price_window.json`、`benchmark_window.json` +- 关键字段:`comp_con_*`、`con_targ_price`、`close` +- 建议图表类型:对比柱图 + 收益曲线 +- 回答问题:市场是否把这次财报解读为正面还是负面 +- 放置位置:`## 市场预期、卖方反馈与价格反应` +- 若图表缺失:保留预期对比表、价格反馈看板和超额收益表 + +## 目标产出 + +- 报告长度:8-12 页 +- 输出文件: + - Markdown 报告 + - HTML 报告(若本地已安装渲染器) +- 输出目录必须由 `--data-dir` / `--output` 指定,不能写死固定路径 + +## 目录结构 + +```text +earnings-analysis/ +├── SKILL.md +├── scripts/ +│ ├── extract_announcements.py +│ └── generate_report.py +├── assets/ +│ └── template.md +└── references/ + ├── data_contract.md + └── web_search.md +``` + +## 输入文件契约 + +原始数据目录由 `--data-dir` 指定,脚本会按下列文件名查找输入: + +- `company_info.json` +- `industry.json` +- `historical_financials.json` +- `roe_history.json` +- `market_cap.json` +- `pe_ratio.json` +- `pb_ratio.json` +- `dividend_yield.json` +- `price_window.json` +- `benchmark_window.json` +- `consensus.json` +- `research_reports.json` +- `announcement_raw.json` +- `announcement_extracts.json`(可选) +- `web_search_findings.json`(可选) + +完整字段说明见 [references/data_contract.md](references/data_contract.md)。 + +## 工作流 + +### 步骤 1:准备参数 + +```bash +REPORT_DATE="${REPORT_DATE:-2026-04-07}" +ORDER_BOOK_ID="${ORDER_BOOK_ID:-600519.XSHG}" +PRICE_WINDOW_START="$(python3 - < "$DATA_DIR/company_info.json" + +rqdata stock cn industry --payload "{ + \"order_book_ids\": [\"$ORDER_BOOK_ID\"], + \"date\": \"$REPORT_DATE\", + \"level\": 0, + \"source\": \"citics_2019\" +}" --format json > "$DATA_DIR/industry.json" + +rqdata stock cn financial --payload "{ + \"order_book_ids\": [\"$ORDER_BOOK_ID\"], + \"fields\": [\"revenue\", \"net_profit\", \"gross_profit\", \"cash_from_operating_activities\", \"total_assets\", \"total_liabilities\"], + \"start_quarter\": \"$HISTORY_START_QUARTER\", + \"end_quarter\": \"$HISTORY_END_QUARTER\", + \"statements\": \"all\" +}" --format json > "$DATA_DIR/historical_financials.json" + +rqdata stock cn financial-indicator --payload "{ + \"order_book_ids\": [\"$ORDER_BOOK_ID\"], + \"factor\": \"return_on_equity_weighted_average\", + \"start_date\": \"$CONSENSUS_START\", + \"end_date\": \"$FACTOR_DATE\" +}" --format json > "$DATA_DIR/roe_history.json" + +rqdata stock cn financial-indicator --payload "{ + \"order_book_ids\": [\"$ORDER_BOOK_ID\"], + \"factor\": \"market_cap\", + \"start_date\": \"$FACTOR_DATE\", + \"end_date\": \"$FACTOR_DATE\" +}" --format json > "$DATA_DIR/market_cap.json" + +rqdata stock cn financial-indicator --payload "{ + \"order_book_ids\": [\"$ORDER_BOOK_ID\"], + \"factor\": \"pe_ratio\", + \"start_date\": \"$FACTOR_DATE\", + \"end_date\": \"$FACTOR_DATE\" +}" --format json > "$DATA_DIR/pe_ratio.json" + +rqdata stock cn financial-indicator --payload "{ + \"order_book_ids\": [\"$ORDER_BOOK_ID\"], + \"factor\": \"pb_ratio\", + \"start_date\": \"$FACTOR_DATE\", + \"end_date\": \"$FACTOR_DATE\" +}" --format json > "$DATA_DIR/pb_ratio.json" + +rqdata stock cn financial-indicator --payload "{ + \"order_book_ids\": [\"$ORDER_BOOK_ID\"], + \"factor\": \"dividend_yield\", + \"start_date\": \"$FACTOR_DATE\", + \"end_date\": \"$FACTOR_DATE\" +}" --format json > "$DATA_DIR/dividend_yield.json" + +rqdata stock cn price --payload "{ + \"order_book_ids\": [\"$ORDER_BOOK_ID\"], + \"start_date\": \"$PRICE_WINDOW_START\", + \"end_date\": \"$REPORT_DATE\", + \"fields\": [\"close\", \"volume\", \"total_turnover\"], + \"adjust_type\": \"post\" +}" --format json > "$DATA_DIR/price_window.json" + +rqdata index price --payload "{ + \"order_book_ids\": [\"000300.XSHG\"], + \"start_date\": \"$PRICE_WINDOW_START\", + \"end_date\": \"$REPORT_DATE\", + \"fields\": [\"close\"] +}" --format json > "$DATA_DIR/benchmark_window.json" +``` + +### 步骤 3:采集市场预期、研报与公告 + +```bash +rqdata stock cn consensus --payload "{ + \"order_book_ids\": [\"$ORDER_BOOK_ID\"], + \"start_date\": \"$CONSENSUS_START\", + \"end_date\": \"$REPORT_DATE\", + \"report_range\": 3 +}" --format json > "$DATA_DIR/consensus.json" + +TARGET_FISCAL_YEAR="$(python3 - "$DATA_DIR/historical_financials.json" "$REPORT_DATE" <<'PY' +import json +import sys +from datetime import date, datetime +from pathlib import Path + +def parse_iso_date(value): + if not value: + return None + for fmt in ("%Y-%m-%d", "%Y-%m-%d %H:%M:%S"): + try: + return datetime.strptime(str(value)[:19], fmt).date() + except ValueError: + continue + return None + +rows = json.loads(Path(sys.argv[1]).read_text()) +report_date = date.fromisoformat(sys.argv[2]) +best = None +for row in rows: + info_date = parse_iso_date(row.get("info_date")) + quarter = row.get("quarter") + if not info_date or info_date > report_date or not quarter: + continue + if best is None or quarter > best: + best = quarter +print(best[:4] if best else report_date.year) +PY +)" + +rqdata stock cn research-reports --payload "{ + \"order_book_ids\": [\"$ORDER_BOOK_ID\"], + \"fiscal_year\": \"$TARGET_FISCAL_YEAR\", + \"start_date\": \"$CONSENSUS_START\", + \"end_date\": \"$REPORT_DATE\", + \"date_rule\": \"create_tm\" +}" --format json > "$DATA_DIR/research_reports.json" + +ANNOUNCEMENT_START="$(python3 - < "$DATA_DIR/announcement_raw.json" +``` + +### 步骤 4:提取公告原文片段 + +```bash +python3 earnings-analysis/scripts/extract_announcements.py \ + --stock "$ORDER_BOOK_ID" \ + --data-dir "$DATA_DIR" \ + --report-date "$REPORT_DATE" +``` + +要求: + +- 若公告源站可读,会生成 `announcement_extracts.json` +- `announcement_extracts.json` 保留 `raw_sections`、`summaries`、失败状态和原文链接 +- 年报 / 中报正文优先抽取 `company_intro`、`management_discussion`、`risk_warning`、`outlook` +- 若源站阻断或 PDF 不可读,也不能静默丢失 + +### 步骤 4.5:当前 LLM 回写 `announcement_extracts.json` summary + +- 直接读取 `records[].raw_sections.*` +- 回写 `records[].summaries.*` +- 不额外创建 `announcement_summaries.json` +- `summaries.*` 必须是客户可读表述,不得直接复制 `raw_sections.*` 原文、不允许粘贴 PDF 抽取碎片 +- 这一步必须由当前 LLM 完成;`generate_report.py` 不负责代写公告正文描述 +- 若年报 / 中报已有可用 `raw_sections.*`,但 `summaries.*` 仍为空或只是原文截断,视为 workflow 未完成,不进入成稿阶段 + +### 步骤 5:补充 `web_search` 实时信息 + +仅当需要补充财报电话会、管理层外部表态、行业或政策语境时,才执行此步骤。 + +- 检索结果必须先写入 `web_search_findings.json` +- 只能补充 `RQData CLI` 直接缺失的信息 +- 不得把新闻稿当作财务主数据 + +### 步骤 6:生成结构化 Markdown 草稿 + +```bash +python3 earnings-analysis/scripts/generate_report.py \ + --stock "$ORDER_BOOK_ID" \ + --data-dir "$DATA_DIR" \ + --report-date "$REPORT_DATE" \ + --output "$OUTPUT_MD" +``` + +说明: + +- 该脚本生成结构化事实草稿,包含表格、信号、研报摘录、公告原文链接和可选外部实时补充信息 +- 不应在 Python 里写“因此看多 / 看空”这类分析句 +- 若已产出 `announcement_extracts.json`,报告只消费其中 `summaries` +- 不允许在报告生成阶段根据 `raw_sections` 现写客户可读公告摘要;`summaries` 缺失时只能明确提示缺失 + +### 步骤 7:渲染 HTML + +脚本会优先尝试本地安装的 `rq-report-renderer`,若不可用则回退到仓库内 `report-renderer/scripts/render_report.py`;两者都不可用时保留 Markdown 并打印警告。 + +## 阶段门控 + +### 阶段 1:数据采集完成标准 + +- 财务、价格、估值、一致预期、研报和公告原始文件齐全 +- 最新财报季度已按 `info_date <= report-date` 自动识别 +- 若使用 `web_search`,`web_search_findings.json` 已结构化落盘 + +### 阶段 2:研究核查完成标准 + +- 已核查财报季度、披露日、价格窗口、预期快照和相关公告 +- 已核查 `announcement_extracts.json -> summaries.*` 为当前 LLM 回写的客户可读摘要,不是原文截断 +- 已确认卖方研报相关性,不包含行业周报 / 策略报告污染 +- 公告原文提取成功或失败状态都已保留 + +### 阶段 3:成稿完成标准 + +- 模板占位符全部替换 +- 关键章节完整 +- 关键结论后有来源和置信度 +- 公告章节中的文字描述仅来自 `announcement_extracts.json -> summaries.*` +- 图表未生成时,表格降级仍能覆盖趋势、质量和价格反应 + +## 模板规则 + +- 报告必须严格基于 [template.md](assets/template.md) 生成 +- 占位符采用 `[[TOKEN]]` 语法,不使用 Jinja +- 当前模板仅允许以下占位符: + - `[[REPORT_DATE]]` + - `[[COMPANY_NAME]]` + - `[[STOCK_CODE]]` + - `[[LATEST_QUARTER]]` + - `[[EVENT_DATE]]` + - `[[EXEC_SUMMARY]]` + - `[[INFO_PANEL]]` + - `[[EARNINGS_OVERVIEW]]` + - `[[EXPECTATION_AND_REACTION]]` + - `[[ANNOUNCEMENT_SECTION]]` + - `[[FINANCIAL_QUALITY]]` + - `[[THESIS_UPDATE]]` + - `[[VALUATION_SECTION]]` + - `[[RISK_SECTION]]` + - `[[APPENDIX]]` + +## 报告质量要求 + +- 完整包含模板中的主章节 +- 关键结论必须基于真实财报、预期和价格反应数据 +- “超预期 / 符合预期 / 低于预期”必须给出口径 +- `research_reports.json` 中的 `summary` 必须作为卖方文字解释层输出 +- 公告原文链接必须保留 +- 公告正文描述必须先由当前 LLM 回写到 `announcement_extracts.json -> summaries.*` +- `dividend_yield` 原始值单位为 bps,报告中必须换算为百分比 +- 若最新财报季度、关键财务指标或价格窗口缺失,生成器应直接失败 +- 若财报前后一致预期字段在 `*_t` 为空、但在更靠后的 forecast slot 非空,生成器必须继续核查并读取实际可用口径,不能机械把财报后预期写成“无数据” +- 若使用 `web_search`,必须保留来源名称、链接、发布时间和检索时间 + +## 阶段验收清单 + +- [ ] 数据采集 -> 报告生成 -> HTML 渲染三阶段都按顺序执行 +- [ ] `RQData CLI` 与 `web_search` 的边界没有混用 +- [ ] 财报原文、预期对比、价格反应都有真实数据支撑 +- [ ] `announcement_extracts.json -> summaries.*` 已由当前 LLM 回写成客户可读内容,而不是原文截断 +- [ ] 卖方研报做过相关性过滤 +- [ ] 公告原文链接和提取状态都保留 +- [ ] 图表未生成时,表格降级仍覆盖核心问题 +- [ ] Markdown / HTML 报告达到 8-12 页目标质量 + +## 常见错误 + +- 使用仓库级 `utils/detect_latest_quarter.py` +- 把 `financial-indicator` 误写成 `fields + start_quarter/end_quarter` +- 财报后分析却没有财报前预期口径 +- 看到 `consensus` 的 `*_t` 为空,就直接把财报后预期写成“无数据”,没有继续核查 `*_t1 / *_t2` +- 把行业周报、策略报告直接当成公司财报点评写进正文 +- 用 `web_search` 替代财报、估值、价格、公告和一致预期主源 diff --git a/.cursor/skills/earnings-analysis/assets/template.md b/.cursor/skills/earnings-analysis/assets/template.md new file mode 100644 index 000000000..c6f458f46 --- /dev/null +++ b/.cursor/skills/earnings-analysis/assets/template.md @@ -0,0 +1,36 @@ +# 财报分析报告 + +- 报告日期:[[REPORT_DATE]] +- 公司:[[COMPANY_NAME]]([[STOCK_CODE]]) +- 最新财报期:[[LATEST_QUARTER]] +- 披露日:[[EVENT_DATE]] + +## 执行摘要 +[[EXEC_SUMMARY]] + +## 信息截面 +[[INFO_PANEL]] + +## 财报概览 +[[EARNINGS_OVERVIEW]] + +## 市场预期、卖方反馈与价格反应 +[[EXPECTATION_AND_REACTION]] + +## 公告原文与管理层表述 +[[ANNOUNCEMENT_SECTION]] + +## 财务质量与资产负债表 +[[FINANCIAL_QUALITY]] + +## 投资逻辑更新 +[[THESIS_UPDATE]] + +## 估值与定位 +[[VALUATION_SECTION]] + +## 风险提示 +[[RISK_SECTION]] + +## 附录:生成说明 +[[APPENDIX]] diff --git a/.cursor/skills/earnings-analysis/references/data_contract.md b/.cursor/skills/earnings-analysis/references/data_contract.md new file mode 100644 index 000000000..5f3598e3f --- /dev/null +++ b/.cursor/skills/earnings-analysis/references/data_contract.md @@ -0,0 +1,243 @@ +# earnings-analysis 数据契约 + +`earnings-analysis/scripts/generate_report.py` 默认从 `--data-dir` 读取以下 JSON 文件。 + +## 1. `company_info.json` + +典型字段: + +- `order_book_id` +- `symbol` +- `abbrev_symbol` +- `industry_name` +- `listed_date` + +用途: + +- 公司名称、简称、上市信息 + +## 2. `industry.json` + +典型字段: + +- `first_industry_name` +- `second_industry_name` +- `third_industry_name` + +用途: + +- 行业分层描述 + +## 3. `historical_financials.json` + +典型字段: + +- `order_book_id` +- `quarter` +- `info_date` +- `revenue` +- `net_profit` +- `gross_profit` +- `cash_from_operating_activities` +- `total_assets` +- `total_liabilities` + +用途: + +- 自动识别最新财报季度 +- 计算同比、环比、毛利率、现金转化率、资产负债率 + +## 4. `roe_history.json` + +典型字段: + +- `order_book_id` +- `date` +- `return_on_equity_weighted_average` + +用途: + +- 盈利质量趋势 + +## 5. `market_cap.json` / `pe_ratio.json` / `pb_ratio.json` / `dividend_yield.json` + +典型字段: + +- `order_book_id` +- `date` +- 对应 factor 字段 + +说明: + +- `dividend_yield` 原始值为 bps,生成报告时需要除以 `100` 后按百分比展示 + +用途: + +- 当前估值与股东回报定位 + +## 6. `price_window.json` + +典型字段: + +- `order_book_id` +- `datetime` +- `close` +- `volume` +- `total_turnover` + +用途: + +- 计算财报前后价格反应 +- 计算成交额放大 + +## 7. `benchmark_window.json` + +典型字段: + +- `order_book_id` +- `datetime` +- `close` + +用途: + +- 计算相对沪深300的超额收益 + +## 8. `consensus.json` + +典型字段: + +- `date` +- `create_tm` +- `report_year_t` +- `comp_con_operating_revenue_t / t1 / t2 / t3` +- `comp_con_net_profit_t / t1 / t2 / t3` +- `comp_con_eps_t / t1 / t2 / t3` +- `con_targ_price` + +用途: + +- 财报前一致预期 +- 财报后预期变化 + +## 9. `research_reports.json` + +典型字段: + +- `create_tm` +- `date` +- `report_title` +- `summary` +- `institute` +- `author` +- `fiscal_year` +- `net_profit_t / t1 / t2` +- `eps_t / t1 / t2` +- `targ_price` + +用途: + +- 财报后卖方解读 +- 目标价和年度利润口径的补充 + +说明: + +- `summary` 是财报后“文字解释层”的首选字段,用于补充卖方对业绩、预期修正和核心关注点的描述 +- `report_title`、`summary`、`targ_price` 与 `net_profit_t / t1 / t2` 需要一起看,不能只保留数值预测 + +## 10. `announcement_raw.json` + +典型字段: + +- `info_date` +- `title` +- `info_type` +- `media` +- `file_type` +- `announcement_link` + +用途: + +- 识别正式财报、主要经营数据公告和业绩说明会公告 +- 在正文中保留公告原文链接,供后续 PDF / HTML 读取 + +## 11. `announcement_extracts.json` + +该文件可选,可由具备 PDF / HTML 原文解析能力的流程生成。 + +典型字段: + +- `records[].title` +- `records[].info_date` +- `records[].announcement_link` +- `records[].is_annual_or_interim_report` +- `records[].fetch_status` +- `records[].extract_status` +- `records[].raw_sections.company_intro` +- `records[].raw_sections.management_discussion` +- `records[].raw_sections.risk_warning` +- `records[].raw_sections.outlook` +- `records[].summaries.company_intro` +- `records[].summaries.management_discussion` +- `records[].summaries.risk_warning` +- `records[].summaries.outlook` +- `records[].sections.company_intro` +- `records[].sections.management_discussion` +- `records[].sections.risk_warning` +- `records[].sections.outlook` + +用途: + +- `raw_sections` 保存较长原文段落,供当前 skill 内的 LLM 直接读取 +- `summaries` 保存基于 `raw_sections` 回写的总结性文本;报告生成时只消费该层 +- `summaries` 必须由当前 LLM 回写客户可读摘要,不能直接复制 `raw_sections` 原文或 PDF 抽取碎片 +- `sections` 为兼容旧结构保留,当前可视为 `raw_sections` 的兼容镜像 +- `company_intro` / `management_discussion` / `outlook` 主要面向年报、半年报正文;季度报告和临时公告默认不强制抽取这三类字段 +- 若源站拦截或 PDF 不可读,也必须保留失败状态和原文链接,不能静默丢失 +- 不额外创建 `announcement_summaries.json`;LLM 应直接在 `announcement_extracts.json` 的 `records[].summaries.*` 中回写结果 + +## 12. `web_search_findings.json` + +该文件可选,仅用于补充 `RQData CLI` 无法直接提供的实时外部语境。 + +每条记录至少包含: + +- `query` +- `source_name` +- `source_type` +- `title` +- `url` +- `published_at` +- `retrieved_at` +- `summary` +- `why_relevant` +- `confidence` +- `finding_type` + +推荐附加字段: + +- `subject` +- `stance` + +允许的 `finding_type`: + +- `company_news` +- `management_update` +- `earnings_call` +- `industry_context` +- `policy_context` + +说明: + +- `published_at` 是源内容发布时间,不是财报披露日 +- `retrieved_at` 是实际检索时间 +- `source_type` / `confidence` 需遵守 `references/web_search.md` 的来源等级约束 +- `web_search_findings.json` 不能替代财务、估值、价格、公告和一致预期主源 + +## 解析约定 + +- 所有文件都允许 `{"data": [...]}`、`{"data": {...}}`、`[...]`、`{...}` 四种包装方式 +- 同一季度多次披露时,脚本按 `info_date <= report-date` 选择最新版本 +- 财报分析使用的目标年度是最新已披露财报年度;读取 `consensus` 时不能机械假设 `*_t` 一定非空,必须继续核查实际有值的 forecast slot +- 研报必须做相关性过滤,至少要求标题或摘要命中公司名称/代码/英文名关键词 +- 可直接运行 `earnings-analysis/scripts/extract_announcements.py` 生成该文件;若源站阻断,也应保留失败状态 +- 若存在 `web_search_findings.json`,脚本会校验必填字段、来源类别和置信度上限 diff --git a/.cursor/skills/earnings-analysis/references/web_search.md b/.cursor/skills/earnings-analysis/references/web_search.md new file mode 100644 index 000000000..918c393b8 --- /dev/null +++ b/.cursor/skills/earnings-analysis/references/web_search.md @@ -0,0 +1,99 @@ +# Earnings Analysis Web Search Reference + +## Purpose + +Use `web_search` only to supplement real-time information that `RQData CLI` does not directly provide for a post-earnings report. + +## Allowed Coverage + +- Earnings call schedules or management public remarks +- Important post-earnings company news +- Industry or policy context relevant to the earnings interpretation +- External signals that help explain expectation revisions or market reaction + +## Prohibited Usage + +- Do not replace financial statements, valuation multiples, prices, official announcements, or consensus data +- Do not use `web_search` to fabricate earnings dates or official disclosure details +- Do not promote low-confidence media snippets into core earnings conclusions + +## Required Output File + +All external findings must be written to `web_search_findings.json`. + +Each record must contain: + +- `query` +- `source_name` +- `source_type` +- `title` +- `url` +- `published_at` +- `retrieved_at` +- `summary` +- `why_relevant` +- `confidence` +- `finding_type` + +Recommended fields: + +- `subject` +- `stance` + +## Allowed `finding_type` + +- `company_news` +- `management_update` +- `earnings_call` +- `industry_context` +- `policy_context` + +## Source Types And Confidence Ceiling + +- `official`: max confidence `5` +- `government`: max confidence `4` +- `association`: max confidence `4` +- `authoritative_media`: max confidence `4` +- `general_news`: max confidence `3` +- `inference`: max confidence `1` + +## 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 a short summary and a concrete relevance note. +5. If confidence is low, keep it as context only and do not let it dominate the conclusion. + +## 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 context as unavailable or unverified + - lower confidence rather than guessing + +## Example + +```json +{ + "data": [ + { + "query": "贵州茅台 2026 业绩说明会 时间", + "source_name": "贵州茅台官网", + "source_type": "official", + "title": "2025年度业绩说明会召开公告", + "url": "https://www.example.com/ir-call", + "published_at": "2026-03-30", + "retrieved_at": "2026-04-07", + "summary": "公司披露 2025 年度业绩说明会将在 4 月中旬召开。", + "why_relevant": "有助于判断财报后管理层沟通节奏和市场关注焦点。", + "confidence": 5, + "finding_type": "earnings_call", + "subject": "业绩说明会", + "stance": "neutral" + } + ] +} +``` diff --git a/.cursor/skills/earnings-analysis/scripts/extract_announcements.py b/.cursor/skills/earnings-analysis/scripts/extract_announcements.py new file mode 100644 index 000000000..804629850 --- /dev/null +++ b/.cursor/skills/earnings-analysis/scripts/extract_announcements.py @@ -0,0 +1,663 @@ +#!/usr/bin/env python3 +"""Extract structured announcement snippets for earnings-analysis.""" + +from __future__ import annotations + +import argparse +import json +import re +import zlib +from datetime import date +from pathlib import Path +from typing import Any, Dict, List, Optional, Sequence, Tuple + +import requests + +from generate_report import ( + build_snapshot, + dedupe_financial_records, + extract_records, + parse_iso_date, + read_json_file, + select_relevant_announcements, +) + +USER_AGENT = ( + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" +) +ACW_POS_LIST = [ + 0x0F, + 0x23, + 0x1D, + 0x18, + 0x21, + 0x10, + 0x01, + 0x26, + 0x0A, + 0x09, + 0x13, + 0x1F, + 0x28, + 0x1B, + 0x16, + 0x17, + 0x19, + 0x0D, + 0x06, + 0x0B, + 0x27, + 0x12, + 0x14, + 0x08, + 0x0E, + 0x15, + 0x20, + 0x1A, + 0x02, + 0x1E, + 0x07, + 0x04, + 0x11, + 0x05, + 0x03, + 0x1C, + 0x22, + 0x25, + 0x0C, + 0x24, +] +ACW_MASK = "3000176000856006061501533003690027800375" +OBJ_RE = re.compile(rb"(\d+)\s+(\d+)\s+obj\b(.*?)endobj", re.S) +STREAM_RE = re.compile(rb"<<(.*?)>>\s*stream\r?\n(.*?)\r?\nendstream", re.S) +PAGE_RE = re.compile(rb"/Type\s*/Page\b") +TEXT_OP_RE = re.compile( + r"/([A-Za-z0-9]+)\s+[0-9.]+\s+Tf|" + r"<([0-9A-Fa-f\s]+)>\s*Tj|" + r"\[(.*?)\]\s*TJ|" + r"\(((?:\\.|[^\\)])*)\)\s*Tj|" + r"(-?[0-9.]+)\s+(-?[0-9.]+)\s+T[Dd]|" + r"T\*|BT|ET", + re.S, +) +TEXT_SECTION_STOP_MARKERS = [ + "重要内容提示", + "一、主要财务数据", + "二、股东信息", + "三、其他提醒事项", + "四、季度财务报表", + "五、重要事项", + "六、其他事项", + "风险提示", + "重大风险提示", + "经营情况讨论与分析", + "管理层讨论与分析", + "投资者关系活动主要内容介绍", + "未来展望", + "经营计划", + "发展战略", +] + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="提取公告 PDF 正文片段并生成 announcement_extracts.json") + parser.add_argument("--stock", required=True, help="股票代码") + parser.add_argument("--data-dir", required=True, help="原始 JSON 数据目录") + parser.add_argument("--report-date", required=True, help="报告日期 (YYYY-MM-DD)") + parser.add_argument("--output", help="输出 JSON 路径,默认写到 data-dir/announcement_extracts.json") + parser.add_argument("--timeout", type=float, default=20.0, help="公告抓取超时时间,默认 20 秒") + return parser.parse_args() + + +def calc_sse_acw_cookie(arg1: str) -> str: + out = [""] * len(ACW_POS_LIST) + for idx, char in enumerate(arg1): + for out_idx, pos in enumerate(ACW_POS_LIST): + if pos == idx + 1: + out[out_idx] = char + break + arg2 = "".join(out) + pieces = [] + for idx in range(0, min(len(arg2), len(ACW_MASK)), 2): + pieces.append(f"{int(arg2[idx:idx + 2], 16) ^ int(ACW_MASK[idx:idx + 2], 16):02x}") + return "".join(pieces) + + +def fetch_pdf_bytes(url: str, timeout: float) -> Tuple[Optional[bytes], str]: + headers = {"User-Agent": USER_AGENT, "Accept": "application/pdf,text/html,*/*", "Referer": url} + session = requests.Session() + + try: + response = session.get(url, timeout=timeout, headers=headers, allow_redirects=True) + except requests.RequestException as exc: + return None, f"network_error:{type(exc).__name__}" + + content_type = (response.headers.get("content-type") or "").lower() + if response.ok and (content_type.startswith("application/pdf") or response.content.startswith(b"%PDF-")): + return response.content, "ok" + + if "static.sse.com.cn" in response.url and "text/html" in content_type: + match = re.search(r"arg1='([^']+)'", response.text) + if not match: + return None, "source_blocked:sse_html_without_arg1" + cookie = calc_sse_acw_cookie(match.group(1)) + session.cookies.set("acw_sc__v2", cookie, domain="static.sse.com.cn", path="/") + try: + retry = session.get( + url, + timeout=timeout, + headers={"User-Agent": USER_AGENT, "Accept": "application/pdf,*/*", "Referer": "http://www.sse.com.cn/"}, + allow_redirects=True, + ) + except requests.RequestException as exc: + return None, f"network_error:{type(exc).__name__}" + retry_type = (retry.headers.get("content-type") or "").lower() + if retry.ok and (retry_type.startswith("application/pdf") or retry.content.startswith(b"%PDF-")): + return retry.content, "ok" + return None, f"source_blocked:sse_retry_{retry.status_code}" + + if not response.ok: + return None, f"http_{response.status_code}" + return None, f"unsupported_content_type:{content_type or 'unknown'}" + + +def parse_pdf_objects(pdf_bytes: bytes) -> Dict[int, bytes]: + return {int(match.group(1)): match.group(3) for match in OBJ_RE.finditer(pdf_bytes)} + + +def parse_stream(raw_object: bytes) -> Tuple[Optional[bytes], Optional[bytes]]: + match = STREAM_RE.search(raw_object) + if not match: + return None, None + stream_dict = match.group(1) + stream_data = match.group(2) + if b"/FlateDecode" in stream_dict: + stream_data = zlib.decompress(stream_data) + return stream_dict, stream_data + + +def decode_utf16be_hex(value: str) -> str: + return bytes.fromhex(value).decode("utf-16-be", "ignore") + + +def build_cmap(stream_text: str) -> Dict[str, str]: + cmap: Dict[str, str] = {} + for block in re.findall(r"beginbfchar\s*(.*?)\s*endbfchar", stream_text, re.S): + for src, dst in re.findall(r"<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>", block): + cmap[src.upper()] = decode_utf16be_hex(dst) + for block in re.findall(r"beginbfrange\s*(.*?)\s*endbfrange", stream_text, re.S): + for start, end, dst in re.findall(r"<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>", block): + start_int = int(start, 16) + end_int = int(end, 16) + dst_int = int(dst, 16) + width = len(start) + out_len = len(dst) // 2 + for idx, code in enumerate(range(start_int, end_int + 1)): + cmap[f"{code:0{width}X}"] = (dst_int + idx).to_bytes(out_len, "big").decode("utf-16-be", "ignore") + for start, _end, arr in re.findall(r"<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>\s*\[(.*?)\]", block, re.S): + start_int = int(start, 16) + width = len(start) + for idx, dst in enumerate(re.findall(r"<([0-9A-Fa-f]+)>", arr)): + cmap[f"{start_int + idx:0{width}X}"] = decode_utf16be_hex(dst) + return cmap + + +def decode_pdf_hex(hex_text: str, cmap: Dict[str, str]) -> str: + hex_text = re.sub(r"\s+", "", hex_text) + if not hex_text: + return "" + key_lengths = sorted({len(key) for key in cmap}, reverse=True) if cmap else [2] + cursor = 0 + output: List[str] = [] + while cursor < len(hex_text): + matched = False + for width in key_lengths: + key = hex_text[cursor:cursor + width].upper() + if len(key) == width and key in cmap: + output.append(cmap[key]) + cursor += width + matched = True + break + if matched: + continue + chunk = hex_text[cursor:cursor + 2] + if len(chunk) == 2: + try: + output.append(bytes.fromhex(chunk).decode("latin1")) + except ValueError: + pass + cursor += 2 + return "".join(output) + + +def decode_pdf_literal(text: str) -> str: + return ( + text.replace(r"\(", "(") + .replace(r"\)", ")") + .replace(r"\n", "\n") + .replace(r"\r", "") + .replace(r"\t", "\t") + .replace(r"\\", "\\") + ) + + +def extract_pdf_text(pdf_bytes: bytes) -> str: + objects = parse_pdf_objects(pdf_bytes) + font_cmaps: Dict[int, Dict[str, str]] = {} + for obj_num, raw_object in objects.items(): + match = re.search(rb"/ToUnicode\s+(\d+)\s+0\s+R", raw_object) + if not match: + continue + stream_ref = int(match.group(1)) + if stream_ref not in objects: + continue + _stream_dict, stream_data = parse_stream(objects[stream_ref]) + if not stream_data: + continue + font_cmaps[obj_num] = build_cmap(stream_data.decode("latin1", "ignore")) + + pages: List[Tuple[int, List[int], Dict[str, int]]] = [] + for obj_num, raw_object in objects.items(): + if not PAGE_RE.search(raw_object): + continue + content_refs = [int(value) for value in re.findall(rb"/Contents\s+(\d+)\s+0\s+R", raw_object)] + if not content_refs: + array_match = re.search(rb"/Contents\s*\[(.*?)\]", raw_object, re.S) + if array_match: + content_refs = [int(value) for value in re.findall(rb"(\d+)\s+0\s+R", array_match.group(1))] + font_map: Dict[str, int] = {} + font_block = re.search(rb"/Font\s*<<(.+?)>>", raw_object, re.S) + if font_block: + for font_name, font_ref in re.findall(rb"/([A-Za-z0-9]+)\s+(\d+)\s+0\s+R", font_block.group(1)): + font_map[font_name.decode("ascii", "ignore")] = int(font_ref) + pages.append((obj_num, content_refs, font_map)) + pages.sort(key=lambda item: item[0]) + + lines: List[str] = [] + current_font: Optional[str] = None + for _page_num, content_refs, font_map in pages: + for content_ref in content_refs: + if content_ref not in objects: + continue + _stream_dict, stream_data = parse_stream(objects[content_ref]) + if not stream_data: + continue + content_text = stream_data.decode("latin1", "ignore") + current_line: List[str] = [] + for match in TEXT_OP_RE.finditer(content_text): + token = match.group(0) + if " Tf" in token: + current_font = match.group(1) + continue + if token == "BT": + current_line = [] + continue + if token == "ET": + line = "".join(current_line).strip() + if line: + lines.append(line) + current_line = [] + continue + if token == "T*" or token.endswith("TD") or token.endswith("Td"): + if match.group(6) and abs(float(match.group(6))) > 1e-6: + line = "".join(current_line).strip() + if line: + lines.append(line) + current_line = [] + continue + if token.endswith("Tj") and token.startswith("<"): + font_ref = font_map.get(current_font or "") + cmap = font_cmaps.get(font_ref, {}) + current_line.append(decode_pdf_hex(match.group(2), cmap)) + continue + if token.endswith("TJ"): + font_ref = font_map.get(current_font or "") + cmap = font_cmaps.get(font_ref, {}) + segment = match.group(3) or "" + for hex_group in re.findall(r"<([0-9A-Fa-f\s]+)>", segment): + current_line.append(decode_pdf_hex(hex_group, cmap)) + for literal in re.findall(r"\(((?:\\.|[^\\)])*)\)", segment): + current_line.append(decode_pdf_literal(literal)) + continue + current_line.append(decode_pdf_literal(match.group(4))) + + text = "\n".join(line for line in lines if line.strip()) + text = text.replace("\r", "\n").replace("\u3000", "") + text = re.sub(r"[ \t]+\n", "\n", text) + text = re.sub(r"\n{3,}", "\n\n", text) + return text.strip() + + +def squash_text(text: str) -> str: + return re.sub(r"\s+", "", text or "") + + +def clip_text(text: str, limit: int = 260) -> str: + text = str(text or "").strip() + if len(text) <= limit: + return text + return text[: limit - 1].rstrip(",、;: ") + "…" + + +def normalize_section_text(text: str, limit: Optional[int] = None) -> str: + text = str(text or "") + text = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f]", "", text) + text = re.sub(r"\s+", "", text) + if not text: + return "" + meaningful_chars = re.findall(r"[\u4e00-\u9fffA-Za-z0-9,。!?;:、“”‘’()()\-%./]", text) + if len(meaningful_chars) < max(20, int(len(text) * 0.6)): + return "" + if not re.search(r"[\u4e00-\u9fffA-Za-z]", text): + return "" + if limit is None: + return text + return clip_text(text, limit) + + +def is_annual_or_interim_report(title: str, info_type: str) -> bool: + title = str(title or "") + info_type = str(info_type or "") + if not re.search(r"(年度报告|年报|半年度报告|半年报|中报)", title): + return False + if re.search(r"(摘要|英文版|公告|业绩说明会|主要经营数据|信息披露公告)", title): + return False + return "定期报告" in info_type or bool(re.search(r"(年度报告|年报|半年度报告|半年报|中报)", title)) + + +def find_marker_window( + text: str, + markers: Sequence[str], + stop_markers: Sequence[str], + max_chars: int, + forbidden_patterns: Sequence[str] = (), +) -> str: + candidates: List[Tuple[int, str]] = [] + for marker in markers: + start = 0 + while True: + idx = text.find(marker, start) + if idx < 0: + break + candidates.append((idx, marker)) + start = idx + len(marker) + if not candidates: + return "" + + candidates.sort(key=lambda item: item[0]) + for best_start, matched_marker in candidates: + local_context = text[max(0, best_start - 80): min(len(text), best_start + 120)] + if re.search(r"[..。…]{12,}", local_context): + continue + search_start = best_start + len(matched_marker) + end_positions = [ + text.find(stop_marker, search_start) + for stop_marker in stop_markers + if stop_marker not in markers and text.find(stop_marker, search_start) >= 0 + ] + end = min(end_positions) if end_positions else min(len(text), best_start + max_chars) + end = min(end, best_start + max_chars) + snippet = normalize_section_text(text[best_start:end], max_chars) + if snippet and forbidden_patterns and any(pattern in snippet for pattern in forbidden_patterns): + continue + if snippet: + return snippet + return "" + + +def find_sentence_by_keywords(text: str, keywords: Sequence[str], max_chars: int) -> str: + sentences = re.split(r"(?<=[。!?;])", text) + for sentence in sentences: + sentence = sentence.strip() + if sentence and any(keyword in sentence for keyword in keywords): + return normalize_section_text(sentence, max_chars) + collapsed = text + for keyword in keywords: + idx = collapsed.find(keyword) + if idx >= 0: + start = max(0, idx - 40) + end = min(len(collapsed), idx + max_chars) + return normalize_section_text(collapsed[start:end], max_chars) + return "" + + +def extract_sections(title: str, info_type: str, raw_text: str) -> Dict[str, str]: + squashed = squash_text(raw_text) + stop_markers = TEXT_SECTION_STOP_MARKERS + long_form_report = is_annual_or_interim_report(title, info_type) + + company_intro = "" + management_discussion = "" + outlook = "" + if long_form_report: + intro_end = len(squashed) + for marker in ("重要内容提示", "一、主要财务数据"): + idx = squashed.find(marker) + if idx >= 0: + intro_end = min(intro_end, idx) + company_intro = normalize_section_text(squashed[:intro_end] or squashed[:220], 220) + company_intro_marked = find_marker_window( + squashed, + ["公司简介", "公司基本情况", "发行人基本情况"], + stop_markers, + 220, + ) + if company_intro_marked: + company_intro = company_intro_marked + + management_discussion = find_marker_window( + squashed, + [ + "管理层讨论与分析", + "经营情况讨论与分析", + "董事会报告", + "经营回顾", + ], + stop_markers, + 280, + ) + if not management_discussion: + management_discussion = find_sentence_by_keywords( + squashed, + ["经营", "销量", "需求", "增长", "盈利能力", "毛利率", "渠道", "产能"], + 240, + ) + + outlook = find_marker_window( + squashed, + ["未来展望", "经营计划", "发展战略", "未来规划", "下半年展望", "后续规划"], + stop_markers, + 220, + forbidden_patterns=("前瞻性陈述", "注意投资风险"), + ) + if not outlook: + outlook = find_sentence_by_keywords( + squashed, + ["未来", "展望", "预计", "计划", "规划", "目标", "将继续", "后续"], + 220, + ) + + risk_warning = find_marker_window( + squashed, + ["风险提示", "重大风险提示", "风险因素", "重大风险"], + stop_markers, + 220, + ) + + return { + "company_intro": company_intro, + "management_discussion": management_discussion, + "risk_warning": risk_warning, + "outlook": outlook, + } + + +def build_raw_sections(title: str, info_type: str, raw_text: str) -> Dict[str, str]: + squashed = squash_text(raw_text) + stop_markers = TEXT_SECTION_STOP_MARKERS + long_form_report = is_annual_or_interim_report(title, info_type) + + company_intro = "" + management_discussion = "" + outlook = "" + if long_form_report: + intro_end = len(squashed) + for marker in ("重要内容提示", "一、主要财务数据"): + idx = squashed.find(marker) + if idx >= 0: + intro_end = min(intro_end, idx) + company_intro = normalize_section_text(squashed[:intro_end] or squashed[:1200], 1200) + company_intro_marked = find_marker_window( + squashed, + ["公司简介", "公司基本情况", "发行人基本情况"], + stop_markers, + 1400, + ) + if company_intro_marked: + company_intro = company_intro_marked + + management_discussion = find_marker_window( + squashed, + ["管理层讨论与分析", "经营情况讨论与分析", "董事会报告", "经营回顾"], + stop_markers, + 2600, + ) + if not management_discussion: + management_discussion = find_sentence_by_keywords( + squashed, + ["经营", "销量", "需求", "增长", "盈利能力", "毛利率", "渠道", "产能"], + 1600, + ) + + outlook = find_marker_window( + squashed, + ["未来展望", "经营计划", "发展战略", "未来规划", "下半年展望", "后续规划"], + stop_markers, + 1800, + forbidden_patterns=("前瞻性陈述", "注意投资风险"), + ) + if not outlook: + outlook = find_sentence_by_keywords( + squashed, + ["未来", "展望", "预计", "计划", "规划", "目标", "将继续", "后续"], + 1200, + ) + + risk_warning = find_marker_window( + squashed, + ["风险提示", "重大风险提示", "风险因素", "重大风险"], + stop_markers, + 1400, + ) + + return { + "company_intro": company_intro, + "management_discussion": management_discussion, + "risk_warning": risk_warning, + "outlook": outlook, + } + + +def choose_extract_status(sections: Dict[str, str], title: str, info_type: str) -> str: + populated = sum(1 for value in sections.values() if value) + if not is_annual_or_interim_report(title, info_type) and populated == 0: + return "skipped_non_annual_interim" + if populated >= 4: + return "ok" + if populated >= 1: + return "partial" + return "no_sections" + + +def main() -> None: + args = parse_args() + report_date = date.fromisoformat(args.report_date) + data_dir = Path(args.data_dir).expanduser() + output_path = Path(args.output).expanduser() if args.output else data_dir / "announcement_extracts.json" + + financial_records = extract_records(read_json_file(data_dir / "historical_financials.json")) + announcement_records = extract_records(read_json_file(data_dir / "announcement_raw.json")) + if not financial_records: + raise ValueError("缺少 historical_financials.json,无法定位财报事件日") + + deduped_financials = dedupe_financial_records(financial_records, args.stock, report_date) + latest_snapshot = build_snapshot(deduped_financials[-1]) if deduped_financials else None + if latest_snapshot is None: + raise ValueError("未识别到 report-date 之前的最新财报季度") + + selected_announcements = select_relevant_announcements( + announcement_records, + args.stock, + latest_snapshot.info_date, + report_date, + ) + + records: List[Dict[str, Any]] = [] + for item in selected_announcements: + title = str(item.get("title") or "") + link = str(item.get("announcement_link") or "") + info_date = parse_iso_date(item.get("info_date") or item.get("date") or item.get("create_tm")) + empty_sections = { + "company_intro": "", + "management_discussion": "", + "risk_warning": "", + "outlook": "", + } + record: Dict[str, Any] = { + "title": title, + "info_date": info_date.isoformat() if info_date else str(item.get("info_date") or ""), + "announcement_link": link, + "media": item.get("media"), + "info_type": item.get("info_type"), + "is_annual_or_interim_report": is_annual_or_interim_report(title, str(item.get("info_type") or "")), + "fetch_status": "skipped", + "extract_status": "not_started", + "raw_sections": dict(empty_sections), + "summaries": dict(empty_sections), + "sections": dict(empty_sections), + } + + if str(item.get("file_type") or "").upper() != "PDF": + record["fetch_status"] = "unsupported_file_type" + record["extract_status"] = "unsupported" + records.append(record) + continue + + pdf_bytes, fetch_status = fetch_pdf_bytes(link, args.timeout) + record["fetch_status"] = fetch_status + if not pdf_bytes: + record["extract_status"] = "fetch_failed" + records.append(record) + continue + + try: + extracted_text = extract_pdf_text(pdf_bytes) + except Exception as exc: # pragma: no cover - defensive branch for malformed PDFs + record["extract_status"] = f"pdf_parse_failed:{type(exc).__name__}" + records.append(record) + continue + + raw_sections = build_raw_sections(title, str(item.get("info_type") or ""), extracted_text) + sections = extract_sections(title, str(item.get("info_type") or ""), extracted_text) + record["raw_sections"] = raw_sections + record["summaries"] = dict(empty_sections) + record["sections"] = raw_sections + record["extract_status"] = choose_extract_status(raw_sections, title, str(item.get("info_type") or "")) + records.append(record) + + output_path.parent.mkdir(parents=True, exist_ok=True) + payload = { + "stock": args.stock, + "report_date": args.report_date, + "event_date": latest_snapshot.info_date.isoformat(), + "record_count": len(records), + "records": records, + } + output_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + print(f"✅ 公告提取结果已写入:{output_path}") + print(f"相关公告样本:{len(records)} 条") + + +if __name__ == "__main__": + main() diff --git a/.cursor/skills/earnings-analysis/scripts/generate_report.py b/.cursor/skills/earnings-analysis/scripts/generate_report.py new file mode 100644 index 000000000..2761ff5de --- /dev/null +++ b/.cursor/skills/earnings-analysis/scripts/generate_report.py @@ -0,0 +1,1463 @@ +#!/usr/bin/env python3 +"""Template-driven earnings analysis report generator.""" + +from __future__ import annotations + +import argparse +import json +import re +import shutil +import subprocess +from dataclasses import dataclass +from datetime import date, datetime, timedelta +from pathlib import Path +from statistics import median +from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple + + +TOKEN_RE = re.compile(r"\[\[([A-Z0-9_]+)\]\]") +REQUIRED_HEADINGS = [ + "# 财报分析报告", + "## 执行摘要", + "## 信息截面", + "## 财报概览", + "## 市场预期、卖方反馈与价格反应", + "## 公告原文与管理层表述", + "## 财务质量与资产负债表", + "## 投资逻辑更新", + "## 估值与定位", + "## 风险提示", + "## 附录:生成说明", +] + +TICKER_FIELDS = ("order_book_id", "ticker", "stock_code", "symbol", "code") +NAME_FIELDS = ("display_name", "name", "stock_name", "company_name", "symbol_name", "symbol") +WEB_SOURCE_TYPE_LABELS = { + "official": "上市公司公告 / 官方网站 / 交易所披露", + "government": "政府 / 监管 / 行业协会 / 官方机构", + "association": "政府 / 监管 / 行业协会 / 官方机构", + "authoritative_media": "权威财经媒体", + "general_news": "一般新闻源", + "inference": "分析推断 / 估计窗口 / 未验证信息", +} +WEB_SOURCE_TYPE_MAX_CONFIDENCE = { + "official": 5, + "government": 4, + "association": 4, + "authoritative_media": 4, + "general_news": 3, + "inference": 1, +} +WEB_FINDING_TYPES = { + "company_news", + "management_update", + "earnings_call", + "industry_context", + "policy_context", +} +FORECAST_SUFFIXES = ("t", "t1", "t2", "t3") +WEB_FINDING_TYPE_LABELS = { + "company_news": "公司新闻", + "management_update": "管理层动态", + "earnings_call": "业绩交流会", + "industry_context": "行业语境", + "policy_context": "政策语境", +} + + +@dataclass +class QuarterSnapshot: + quarter: str + info_date: date + revenue: Optional[float] + net_profit: Optional[float] + gross_profit: Optional[float] + operating_cash: Optional[float] + total_assets: Optional[float] + total_liabilities: Optional[float] + + +@dataclass +class QuarterDelta: + revenue: Optional[float] + net_profit: Optional[float] + gross_profit: Optional[float] + operating_cash: Optional[float] + + +def parse_args() -> argparse.Namespace: + skill_dir = Path(__file__).resolve().parent.parent + parser = argparse.ArgumentParser(description="生成模板驱动的财报分析报告") + parser.add_argument("--stock", required=True, help="股票代码") + parser.add_argument("--company", help="公司名称,可选") + parser.add_argument("--data-dir", required=True, help="原始 JSON 数据目录") + parser.add_argument("--report-date", required=True, help="报告日期 (YYYY-MM-DD)") + parser.add_argument("--output", 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: + 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 extract_named_records(payload: Any, key: str) -> List[Any]: + if payload is None: + return [] + if isinstance(payload, dict) and isinstance(payload.get(key), list): + return list(payload[key]) + return extract_records(payload) + + +def parse_iso_date(value: Any) -> Optional[date]: + if value in (None, "", "null", "0000-00-00"): + 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 + + candidates = ( + (text, "%Y-%m-%d"), + (text, "%Y/%m/%d"), + (text, "%Y-%m-%d %H:%M:%S"), + (text, "%Y/%m/%d %H:%M:%S"), + (text, "%Y-%m-%dT%H:%M:%S"), + (text, "%Y-%m-%dT%H:%M:%S.%f"), + (text[:19], "%Y-%m-%d %H:%M:%S"), + (text[:19], "%Y/%m/%d %H:%M:%S"), + (text[:19], "%Y-%m-%dT%H:%M:%S"), + (text[:10], "%Y-%m-%d"), + (text[:10], "%Y/%m/%d"), + ) + for raw, fmt in candidates: + try: + return datetime.strptime(raw, fmt).date() + except ValueError: + continue + return None + + +def parse_iso_datetime(value: Any) -> Optional[datetime]: + if value in (None, "", "null"): + return None + text = str(value).strip() + candidates = ( + (text, "%Y-%m-%d %H:%M:%S"), + (text, "%Y/%m/%d %H:%M:%S"), + (text, "%Y-%m-%dT%H:%M:%S"), + (text, "%Y-%m-%dT%H:%M:%S.%f"), + (text, "%Y-%m-%d"), + (text[:19], "%Y-%m-%d %H:%M:%S"), + (text[:19], "%Y/%m/%d %H:%M:%S"), + (text[:19], "%Y-%m-%dT%H:%M:%S"), + (text[:10], "%Y-%m-%d"), + ) + for raw, fmt in candidates: + try: + return datetime.strptime(raw, fmt) + except ValueError: + continue + 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 previous_quarter(quarter: str) -> Optional[str]: + key = parse_quarter_key(quarter) + if not key: + return None + year, q = key + if q == 1: + return f"{year - 1}q4" + return f"{year}q{q - 1}" + + +def yoy_quarter(quarter: str) -> Optional[str]: + key = parse_quarter_key(quarter) + if not key: + return None + return f"{key[0] - 1}q{key[1]}" + + +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 float_or_none(value: Any) -> Optional[float]: + if value in (None, "", "null"): + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + +def percent_text(value: Optional[float], digits: int = 1, signed: bool = True) -> str: + if value is None: + return "无数据" + sign = "+" if signed else "" + return f"{value:{sign}.{digits}f}%" + + +def x_text(value: Optional[float], digits: int = 2) -> str: + if value is None: + return "无数据" + return f"{value:.{digits}f}x" + + +def billion_yuan_text(value: Optional[float]) -> str: + if value is None: + return "无数据" + return f"{value / 1e8:.2f}亿元" + + +def yuan_price_text(value: Optional[float], digits: int = 2) -> str: + if value is None: + return "无数据" + return f"{value:.{digits}f}元" + + +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 safe_growth(current: Optional[float], base: Optional[float]) -> Optional[float]: + if current is None or base in (None, 0): + return None + return (current / base - 1.0) * 100.0 + + +def safe_ratio(current: Optional[float], base: Optional[float]) -> Optional[float]: + if current is None or base in (None, 0): + return None + return current / base + + +def normalize_text(value: Any) -> str: + text = str(value or "") + text = text.replace("\r", "\n").replace("\u0000", "") + text = re.sub(r"[ \t]+\n", "\n", text) + text = re.sub(r"\n{3,}", "\n\n", text) + return text.strip() + + +def compact_text(value: Any, limit: int = 160) -> str: + text = normalize_text(value).replace("\n", " ") + if not text: + return "" + if len(text) <= limit: + return text + return text[:limit].rstrip() + "..." + + +def trim_sentence_end(text: str) -> str: + return text.rstrip("。;;!!?? ") + + +def normalize_web_source_type(value: Any) -> str: + text = str(value or "").strip().lower() + aliases = { + "official_site": "official", + "official_website": "official", + "gov": "government", + "media": "general_news", + "news": "general_news", + } + normalized = aliases.get(text, text) + if normalized not in WEB_SOURCE_TYPE_LABELS: + raise ValueError(f"web_search source_type 不受支持: {value}") + return normalized + + +def parse_confidence(value: Any) -> int: + try: + score = int(value) + except (TypeError, ValueError) as exc: + raise ValueError(f"置信度必须是 1-5 的整数: {value}") from exc + if not 1 <= score <= 5: + raise ValueError(f"置信度必须在 1-5 之间: {value}") + return score + + +def source_confidence_label(source: str, confidence: int) -> str: + return f"{source},置信度{confidence}" + + +def format_data_source_line(source_pairs: Sequence[Tuple[str, int]], *, limit: int = 6) -> str: + labels: List[str] = [] + for source, confidence in source_pairs: + label = source_confidence_label(source, confidence) + if label not in labels: + labels.append(label) + if len(labels) >= limit: + break + if not labels: + return "*数据来源:暂无可展示来源*" + return f"*数据来源:{';'.join(labels)}*" + + +def announcement_status_label(extract_row: Optional[Dict[str, Any]]) -> str: + if not extract_row: + return "保留原文链接,未抽取正文" + + extract_status = str(extract_row.get("extract_status") or "").strip() + fetch_status = str(extract_row.get("fetch_status") or "").strip() + + if extract_status == "ok": + return "已提炼关键片段" + if extract_status == "partial": + return "提炼到部分片段" + if extract_status == "no_sections": + return "未形成可直接引用片段" + if extract_status in {"skipped_non_annual_interim", "not_started", "未提取"}: + return "保留原文链接,未抽取正文" + if extract_status == "unsupported" or fetch_status == "unsupported_file_type": + return "文件格式暂不支持提炼" + if extract_status == "fetch_failed": + return "原文暂不可读,保留链接" + if extract_status.startswith("pdf_parse_failed"): + return "原文解析失败,保留链接" + if fetch_status and fetch_status not in {"ok", "skipped"}: + return "原文获取受限,保留链接" + return "保留原文链接供查阅" + + +def normalize_web_search_findings(records: List[Any], report_date: date) -> List[Dict[str, Any]]: + findings: List[Dict[str, Any]] = [] + for index, item in enumerate(records): + if not isinstance(item, dict): + continue + required_fields = [ + "query", + "source_name", + "source_type", + "title", + "url", + "published_at", + "retrieved_at", + "summary", + "why_relevant", + "confidence", + "finding_type", + ] + missing = [field for field in required_fields if item.get(field) in (None, "")] + if missing: + raise ValueError(f"web_search_findings.json 第 {index + 1} 条缺少字段: {', '.join(missing)}") + + source_type = normalize_web_source_type(item.get("source_type")) + confidence = parse_confidence(item.get("confidence")) + max_confidence = WEB_SOURCE_TYPE_MAX_CONFIDENCE[source_type] + finding_type = str(item.get("finding_type") or "").strip() + if finding_type not in WEB_FINDING_TYPES: + raise ValueError(f"web_search finding_type 不受支持: {finding_type or '空'}") + + published_at = parse_iso_date(item.get("published_at")) + retrieved_at = parse_iso_date(item.get("retrieved_at")) + if not published_at or not retrieved_at: + raise ValueError(f"web_search_findings.json 第 {index + 1} 条的 published_at / retrieved_at 非法") + if published_at > retrieved_at: + raise ValueError(f"web_search_findings.json 第 {index + 1} 条的 published_at 晚于 retrieved_at") + if published_at > report_date: + continue + + stance = str(item.get("stance") or "neutral").strip().lower() + if stance not in {"positive", "neutral", "negative"}: + raise ValueError(f"web_search stance 仅支持 positive / neutral / negative: {stance}") + + findings.append( + { + "query": str(item.get("query") or "").strip(), + "source_name": str(item.get("source_name") or "").strip(), + "source_type": source_type, + "title": str(item.get("title") or "").strip(), + "url": str(item.get("url") or "").strip(), + "published_at": published_at, + "retrieved_at": retrieved_at, + "summary": compact_text(item.get("summary") or "", 220), + "why_relevant": compact_text(item.get("why_relevant") or "", 200), + "confidence": min(confidence, max_confidence), + "finding_type": finding_type, + "subject": str(item.get("subject") or item.get("scope_name") or "外部补充").strip(), + "stance": stance, + } + ) + findings.sort(key=lambda row: (row["published_at"], row["confidence"]), reverse=True) + return findings + + +def extract_section_display_text(extract_row: Dict[str, Any], key: str, limit: int = 220) -> str: + summaries = extract_row.get("summaries") if isinstance(extract_row.get("summaries"), dict) else {} + legacy_sections = extract_row.get("sections") if isinstance(extract_row.get("sections"), dict) else {} + raw_sections = extract_row.get("raw_sections") if isinstance(extract_row.get("raw_sections"), dict) else {} + + if summaries: + value = summaries.get(key) + if value not in (None, ""): + return compact_text(value, limit) + return "" + + # Legacy fallback only applies to pre-upgrade files that do not contain raw_sections/summaries. + if not raw_sections and legacy_sections: + value = legacy_sections.get(key) + if value not in (None, ""): + return compact_text(value, limit) + return "" + + +def markdown_link(label: str, url: Optional[str]) -> str: + if not url: + return label + return f"[{label}]({url})" + + +def to_single_quarter(snapshot: QuarterSnapshot, prev_snapshot: Optional[QuarterSnapshot]) -> QuarterDelta: + quarter = parse_quarter_key(snapshot.quarter) + if quarter is None: + return QuarterDelta(snapshot.revenue, snapshot.net_profit, snapshot.gross_profit, snapshot.operating_cash) + if quarter[1] == 1 or prev_snapshot is None: + return QuarterDelta(snapshot.revenue, snapshot.net_profit, snapshot.gross_profit, snapshot.operating_cash) + return QuarterDelta( + revenue=(snapshot.revenue - prev_snapshot.revenue) if snapshot.revenue is not None and prev_snapshot.revenue is not None else None, + net_profit=(snapshot.net_profit - prev_snapshot.net_profit) if snapshot.net_profit is not None and prev_snapshot.net_profit is not None else None, + gross_profit=(snapshot.gross_profit - prev_snapshot.gross_profit) if snapshot.gross_profit is not None and prev_snapshot.gross_profit is not None else None, + operating_cash=(snapshot.operating_cash - prev_snapshot.operating_cash) if snapshot.operating_cash is not None and prev_snapshot.operating_cash is not None else None, + ) + + +def dedupe_financial_records(records: List[Any], stock: str, report_date: date) -> List[Dict[str, Any]]: + deduped: Dict[str, Dict[str, Any]] = {} + for item in records: + if not isinstance(item, dict): + continue + if normalize_ticker(item) != stock: + continue + quarter = str(item.get("quarter") or "").lower() + info_date = parse_iso_date(item.get("info_date")) + if not parse_quarter_key(quarter) or info_date is None or info_date > report_date: + continue + current = deduped.get(quarter) + current_date = parse_iso_date(current.get("info_date")) if current else None + if current is None or current_date is None or info_date >= current_date: + deduped[quarter] = item + return [deduped[key] for key in sorted(deduped.keys(), key=lambda item: parse_quarter_key(item) or (0, 0))] + + +def build_snapshot(record: Dict[str, Any]) -> QuarterSnapshot: + return QuarterSnapshot( + quarter=str(record.get("quarter") or "").lower(), + info_date=parse_iso_date(record.get("info_date")) or date.min, + revenue=float_or_none(record.get("revenue")), + net_profit=float_or_none(record.get("net_profit")), + gross_profit=float_or_none(record.get("gross_profit")), + operating_cash=float_or_none(record.get("cash_from_operating_activities")), + total_assets=float_or_none(record.get("total_assets")), + total_liabilities=float_or_none(record.get("total_liabilities")), + ) + + +def latest_factor_value(records: List[Any], stock: str, field_name: str, report_date: date) -> Optional[float]: + best: Optional[Tuple[date, float]] = None + for item in records: + if not isinstance(item, dict) or normalize_ticker(item) != stock: + continue + event_date = parse_iso_date(item.get("date") or item.get("datetime")) + value = float_or_none(item.get(field_name)) + if field_name == "dividend_yield" and value is not None: + value = value / 100.0 + if event_date is None or event_date > report_date or value is None: + continue + if best is None or event_date >= best[0]: + best = (event_date, value) + return best[1] if best else None + + +def build_price_points(records: List[Any], stock: str) -> List[Tuple[date, float, Optional[float], Optional[float]]]: + points = [] + for item in records: + if not isinstance(item, dict) or normalize_ticker(item) != stock: + continue + event_date = parse_iso_date(item.get("datetime") or item.get("date")) + close = float_or_none(item.get("close")) + volume = float_or_none(item.get("volume")) + turnover = float_or_none(item.get("total_turnover")) + if event_date is None or close is None: + continue + points.append((event_date, close, volume, turnover)) + points.sort(key=lambda pair: pair[0]) + return points + + +def build_benchmark_points(records: List[Any]) -> List[Tuple[date, float]]: + points = [] + for item in records: + if not isinstance(item, dict): + continue + event_date = parse_iso_date(item.get("datetime") or item.get("date")) + close = float_or_none(item.get("close")) + if event_date is None or close is None: + continue + points.append((event_date, close)) + points.sort(key=lambda pair: pair[0]) + return points + + +def reaction_return(points: Sequence[Tuple[date, float, Optional[float], Optional[float]]], event_date: date, trading_days_after: int) -> Optional[float]: + before = None + after_index = None + for idx, point in enumerate(points): + if point[0] < event_date: + before = point + elif point[0] >= event_date and after_index is None: + after_index = idx + if before is None or after_index is None: + return None + target_index = after_index + trading_days_after - 1 + if target_index >= len(points) or before[1] == 0: + return None + return (points[target_index][1] / before[1] - 1.0) * 100.0 + + +def benchmark_return(points: Sequence[Tuple[date, float]], event_date: date, trading_days_after: int) -> Optional[float]: + before = None + after_index = None + for idx, point in enumerate(points): + if point[0] < event_date: + before = point + elif point[0] >= event_date and after_index is None: + after_index = idx + if before is None or after_index is None: + return None + target_index = after_index + trading_days_after - 1 + if target_index >= len(points) or before[1] == 0: + return None + return (points[target_index][1] / before[1] - 1.0) * 100.0 + + +def median_turnover(points: Sequence[Tuple[date, float, Optional[float], Optional[float]]], before_event: bool, event_date: date) -> Optional[float]: + values = [] + for point_date, _, _, turnover in points: + if turnover is None: + continue + if before_event and point_date < event_date: + values.append(turnover) + if not before_event and point_date >= event_date: + values.append(turnover) + return float(median(values)) if values else None + + +def choose_latest_by_create_time(records: List[Any], predicate) -> Optional[Dict[str, Any]]: + best: Optional[Tuple[datetime, Dict[str, Any]]] = None + for item in records: + if not isinstance(item, dict) or not predicate(item): + continue + event_dt = parse_iso_datetime(item.get("create_tm") or item.get("create_time") or item.get("date")) + if event_dt is None: + continue + if best is None or event_dt >= best[0]: + best = (event_dt, item) + return best[1] if best else None + + +def consensus_year_map(record: Optional[Dict[str, Any]]) -> Dict[int, Dict[str, Optional[float]]]: + if not record: + return {} + report_year_raw = record.get("report_year_t") + try: + base_year = int(str(report_year_raw or date.today().year)) + except ValueError: + base_year = date.today().year + year_map: Dict[int, Dict[str, Optional[float]]] = {} + for field_prefix, key in ( + ("comp_con_operating_revenue", "revenue"), + ("comp_con_net_profit", "profit"), + ("comp_con_eps", "eps"), + ): + for offset, suffix in enumerate(FORECAST_SUFFIXES): + field_name = f"{field_prefix}_{suffix}" + if field_name not in record: + continue + year_map.setdefault(base_year + offset, {})[key] = float_or_none(record.get(field_name)) + if "con_targ_price" in record: + year_map.setdefault(base_year, {})["target_price"] = float_or_none(record.get("con_targ_price")) + return year_map + + +def first_available_consensus_year(record: Optional[Dict[str, Any]]) -> Optional[int]: + year_map = consensus_year_map(record) + available_years = [ + year + for year, metrics in sorted(year_map.items()) + if any(metrics.get(key) is not None for key in ("revenue", "profit", "eps")) + ] + return available_years[0] if available_years else None + + +def consensus_field_value(record: Optional[Dict[str, Any]], prefix: str, target_year: int) -> Optional[float]: + field_key_map = { + "comp_con_operating_revenue": "revenue", + "comp_con_net_profit": "profit", + "comp_con_eps": "eps", + } + metric_key = field_key_map[prefix] + year_map = consensus_year_map(record) + metrics = year_map.get(target_year) + if not metrics: + return None + return metrics.get(metric_key) + + +def company_aliases(info_record: Optional[Dict[str, Any]], stock: str) -> List[str]: + aliases = [stock, stock.split(".")[0]] + if info_record: + symbol = str(info_record.get("symbol") or "").strip() + abbrev = str(info_record.get("abbrev_symbol") or "").strip() + if symbol: + aliases.extend([symbol, symbol.replace("股份", "")]) + if abbrev: + aliases.extend([abbrev, abbrev.replace(" ", "")]) + office = str(info_record.get("office_address") or "").strip() + province = str(info_record.get("province") or "").strip() + if office: + aliases.append(office) + if province: + aliases.append(province) + seen = set() + result = [] + for alias in aliases: + alias = alias.strip() + if alias and alias not in seen: + seen.add(alias) + result.append(alias) + return result + + +def filter_related_reports(records: List[Any], aliases: Sequence[str], report_date: date, event_date: date) -> List[Dict[str, Any]]: + related = [] + lower_aliases = [alias.lower() for alias in aliases] + strong_aliases = [alias for alias in lower_aliases if len(alias) >= 4 or re.search(r"\d", alias)] + stock_code = aliases[1].lower() if len(aliases) > 1 else "" + excluded_title_tokens = ("行业周报", "周报", "月报", "专题", "策略", "宏观", "晨报", "行业点评合集", "持仓分析", "行业配置", "板块") + for item in records: + if not isinstance(item, dict): + continue + create_date = parse_iso_date(item.get("create_tm") or item.get("create_time") or item.get("date")) + if create_date is None or create_date < event_date or create_date > report_date: + continue + title = str(item.get("report_title") or "") + summary = str(item.get("summary") or "") + title_lower = title.lower() + summary_lower = summary.lower() + report_main_id = str(item.get("report_main_id") or "").lower() + title_hit = any(alias in title_lower for alias in strong_aliases) + summary_hit = any(alias in summary_lower for alias in strong_aliases) + report_main_id_hit = bool(stock_code) and stock_code in report_main_id + company_style_title = any(token in title_lower for token in ("点评", "results", "earnings", "季报", "年报", "三季报", "中报", "q1", "q2", "q3", "q4")) + generic_title = any(token.lower() in title_lower for token in excluded_title_tokens) + if generic_title and not title_hit: + continue + if title_hit or report_main_id_hit or (summary_hit and company_style_title): + related.append(item) + related.sort(key=lambda item: str(item.get("create_tm") or item.get("create_time") or item.get("date") or ""), reverse=True) + return related + + +def announcement_score(title: str, info_type: str, event_date: date, report_date: date, info_date: Optional[date]) -> int: + score = 0 + if "定期报告" in info_type: + score += 100 + if re.search(r"(年报|年度报告|半年报|半年度报告|一季报|第一季度报告|三季报|第三季度报告)", title): + score += 80 + if re.search(r"(主要经营数据|业绩说明会|业绩发布会)", title): + score += 50 + if re.search(r"(董事会|监事会|法律意见书|独立财务顾问)", title): + score -= 20 + if re.search(r"(激励计划|股票期权|限制性股票|行权|归属|注销|作废)", title): + score -= 40 + if info_date: + distance = abs((info_date - event_date).days) + score += max(0, 20 - distance) + if info_date > report_date: + score -= 40 + return score + + +def select_relevant_announcements(records: List[Any], stock: str, event_date: date, report_date: date) -> List[Dict[str, Any]]: + candidates = [] + lower_bound = event_date - timedelta(days=10) + upper_bound = report_date + timedelta(days=5) + for item in records: + if not isinstance(item, dict) or normalize_ticker(item) != stock: + continue + info_date = parse_iso_date(item.get("info_date") or item.get("date") or item.get("create_tm")) + if info_date is None or info_date < lower_bound or info_date > upper_bound: + continue + title = str(item.get("title") or "") + info_type = str(item.get("info_type") or "") + score = announcement_score(title, info_type, event_date, report_date, info_date) + if score <= 0: + continue + row = dict(item) + row["_score"] = score + candidates.append(row) + candidates.sort(key=lambda item: (item["_score"], str(item.get("info_date") or "")), reverse=True) + deduped = [] + seen = set() + for item in candidates: + key = (str(item.get("info_date") or ""), str(item.get("title") or "")) + if key in seen: + continue + seen.add(key) + deduped.append(item) + if len(deduped) >= 5: + break + return deduped + + +def validate_dataset(latest_snapshot: Optional[QuarterSnapshot], price_points: Sequence[Tuple[date, float, Optional[float], Optional[float]]], consensus_records: List[Any]) -> None: + issues = [] + if latest_snapshot is None: + issues.append("未识别到 report-date 之前的最新财报季度") + if len(price_points) < 5: + issues.append("股价窗口覆盖不足") + if not consensus_records: + issues.append("一致预期数据为空") + if issues: + raise ValueError("数据质量校验失败:" + ";".join(issues)) + + +def try_render_html(md_path: Path) -> Optional[Path]: + html_path = md_path.with_suffix(".html") + renderer_binary = shutil.which("rq-report-renderer") + 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"警告:仓库内渲染器执行失败:{exc}") + + print("警告:未找到可用的 HTML 渲染器,保留 Markdown 输出") + return None + + +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) + leftovers = sorted(set(TOKEN_RE.findall(report_text))) + if leftovers: + raise ValueError(f"模板占位符未完全替换:{', '.join(leftovers)}") + for heading in REQUIRED_HEADINGS: + if heading not in report_text: + raise ValueError(f"模板缺少必需章节:{heading}") + return report_text + + +def main() -> None: + args = parse_args() + report_date = date.fromisoformat(args.report_date) + data_dir = Path(args.data_dir).expanduser() + + company_records = extract_records(read_json_file(data_dir / "company_info.json")) + industry_records = extract_records(read_json_file(data_dir / "industry.json")) + financial_records = extract_records(read_json_file(data_dir / "historical_financials.json")) + roe_records = extract_records(read_json_file(data_dir / "roe_history.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")) + dividend_records = extract_records(read_json_file(data_dir / "dividend_yield.json")) + price_records = extract_records(read_json_file(data_dir / "price_window.json")) + benchmark_records = extract_records(read_json_file(data_dir / "benchmark_window.json")) + consensus_records = extract_records(read_json_file(data_dir / "consensus.json")) + research_records = extract_records(read_json_file(data_dir / "research_reports.json")) + announcement_records = extract_records(read_json_file(data_dir / "announcement_raw.json")) + announcement_extract_records = extract_named_records(read_json_file(data_dir / "announcement_extracts.json"), "records") + web_search_records = extract_records(read_json_file(data_dir / "web_search_findings.json")) + + company_info = next((item for item in company_records if isinstance(item, dict) and normalize_ticker(item) == args.stock), None) + company_name = args.company or normalize_name(company_info or {}) or args.stock + aliases = company_aliases(company_info, args.stock) + + deduped = dedupe_financial_records(financial_records, args.stock, report_date) + latest_snapshot = build_snapshot(deduped[-1]) if deduped else None + price_points = build_price_points(price_records, args.stock) + validate_dataset(latest_snapshot, price_points, consensus_records) + assert latest_snapshot is not None + web_findings = normalize_web_search_findings(web_search_records, report_date) + + snapshot_map = {item["quarter"]: build_snapshot(item) for item in deduped} + prev_snapshot = snapshot_map.get(previous_quarter(latest_snapshot.quarter) or "") + yoy_snapshot = snapshot_map.get(yoy_quarter(latest_snapshot.quarter) or "") + yoy_prev_snapshot = snapshot_map.get(previous_quarter(yoy_snapshot.quarter) or "") if yoy_snapshot else None + target_year = int(latest_snapshot.quarter[:4]) + event_date = latest_snapshot.info_date + + benchmark_points = build_benchmark_points(benchmark_records) + + consensus_pre = choose_latest_by_create_time(consensus_records, lambda item: parse_iso_date(item.get("date")) and parse_iso_date(item.get("date")) < event_date) + consensus_post = choose_latest_by_create_time(consensus_records, lambda item: parse_iso_date(item.get("date")) and parse_iso_date(item.get("date")) <= report_date) + pre_consensus_year = first_available_consensus_year(consensus_pre) or target_year + post_consensus_year = first_available_consensus_year(consensus_post) or target_year + related_reports = filter_related_reports(research_records, aliases, report_date, event_date) + selected_announcements = select_relevant_announcements(announcement_records, args.stock, event_date, report_date) + announcement_extract_index = {} + for row in announcement_extract_records: + if isinstance(row, dict): + link_key = str(row.get("announcement_link") or "") + title_key = str(row.get("title") or "") + if link_key: + announcement_extract_index[link_key] = row + if title_key: + announcement_extract_index[title_key] = row + + revenue_cons_pre = consensus_field_value(consensus_pre, "comp_con_operating_revenue", pre_consensus_year) + profit_cons_pre = consensus_field_value(consensus_pre, "comp_con_net_profit", pre_consensus_year) + eps_cons_pre = consensus_field_value(consensus_pre, "comp_con_eps", pre_consensus_year) + revenue_cons_post = consensus_field_value(consensus_post, "comp_con_operating_revenue", post_consensus_year) + profit_cons_post = consensus_field_value(consensus_post, "comp_con_net_profit", post_consensus_year) + eps_cons_post = consensus_field_value(consensus_post, "comp_con_eps", post_consensus_year) + target_price_pre = float_or_none((consensus_pre or {}).get("con_targ_price")) + target_price_post = float_or_none((consensus_post or {}).get("con_targ_price")) + + market_cap = latest_factor_value(market_cap_records, args.stock, "market_cap", report_date) + pe_ratio = latest_factor_value(pe_records, args.stock, "pe_ratio", report_date) + pb_ratio = latest_factor_value(pb_records, args.stock, "pb_ratio", report_date) + dividend_yield = latest_factor_value(dividend_records, args.stock, "dividend_yield", report_date) + latest_roe = latest_factor_value(roe_records, args.stock, "return_on_equity_weighted_average", report_date) + + revenue_yoy = safe_growth(latest_snapshot.revenue, yoy_snapshot.revenue if yoy_snapshot else None) + profit_yoy = safe_growth(latest_snapshot.net_profit, yoy_snapshot.net_profit if yoy_snapshot else None) + revenue_qoq = safe_growth(latest_snapshot.revenue, prev_snapshot.revenue if prev_snapshot else None) + profit_qoq = safe_growth(latest_snapshot.net_profit, prev_snapshot.net_profit if prev_snapshot else None) + gross_margin = safe_ratio(latest_snapshot.gross_profit, latest_snapshot.revenue) + gross_margin = gross_margin * 100.0 if gross_margin is not None else None + yoy_gross_margin = safe_ratio(yoy_snapshot.gross_profit if yoy_snapshot else None, yoy_snapshot.revenue if yoy_snapshot else None) + yoy_gross_margin = yoy_gross_margin * 100.0 if yoy_gross_margin is not None else None + gross_margin_change = (gross_margin - yoy_gross_margin) if gross_margin is not None and yoy_gross_margin is not None else None + net_margin = safe_ratio(latest_snapshot.net_profit, latest_snapshot.revenue) + net_margin = net_margin * 100.0 if net_margin is not None else None + debt_ratio = safe_ratio(latest_snapshot.total_liabilities, latest_snapshot.total_assets) + debt_ratio = debt_ratio * 100.0 if debt_ratio is not None else None + cash_conversion = safe_ratio(latest_snapshot.operating_cash, latest_snapshot.net_profit) + revenue_progress = safe_ratio(latest_snapshot.revenue, revenue_cons_pre) + revenue_progress = revenue_progress * 100.0 if revenue_progress is not None else None + profit_progress = safe_ratio(latest_snapshot.net_profit, profit_cons_pre) + profit_progress = profit_progress * 100.0 if profit_progress is not None else None + same_consensus_year = pre_consensus_year == post_consensus_year + revenue_cons_delta = safe_growth(revenue_cons_post, revenue_cons_pre) if same_consensus_year else None + profit_cons_delta = safe_growth(profit_cons_post, profit_cons_pre) if same_consensus_year else None + eps_cons_delta = safe_growth(eps_cons_post, eps_cons_pre) if same_consensus_year else None + target_price_delta = safe_growth(target_price_post, target_price_pre) + price_1d = reaction_return(price_points, event_date, 1) + price_3d = reaction_return(price_points, event_date, 3) + price_5d = reaction_return(price_points, event_date, 5) + benchmark_1d = benchmark_return(benchmark_points, event_date, 1) + benchmark_3d = benchmark_return(benchmark_points, event_date, 3) + benchmark_5d = benchmark_return(benchmark_points, event_date, 5) + excess_1d = price_1d - benchmark_1d if price_1d is not None and benchmark_1d is not None else None + excess_3d = price_3d - benchmark_3d if price_3d is not None and benchmark_3d is not None else None + excess_5d = price_5d - benchmark_5d if price_5d is not None and benchmark_5d is not None else None + turnover_pre = median_turnover(price_points, True, event_date) + turnover_post = median_turnover(price_points, False, event_date) + turnover_change = safe_ratio(turnover_post, turnover_pre) + + latest_single = to_single_quarter(latest_snapshot, prev_snapshot) + yoy_single = to_single_quarter(yoy_snapshot, yoy_prev_snapshot) if yoy_snapshot else QuarterDelta(None, None, None, None) + latest_single_gross_margin = safe_ratio(latest_single.gross_profit, latest_single.revenue) + latest_single_gross_margin = latest_single_gross_margin * 100.0 if latest_single_gross_margin is not None else None + yoy_single_gross_margin = safe_ratio(yoy_single.gross_profit, yoy_single.revenue) + yoy_single_gross_margin = yoy_single_gross_margin * 100.0 if yoy_single_gross_margin is not None else None + latest_single_revenue_yoy = safe_growth(latest_single.revenue, yoy_single.revenue) + latest_single_profit_yoy = safe_growth(latest_single.net_profit, yoy_single.net_profit) + latest_single_cash_yoy = safe_growth(latest_single.operating_cash, yoy_single.operating_cash) + latest_single_margin_change = (latest_single_gross_margin - yoy_single_gross_margin) if latest_single_gross_margin is not None and yoy_single_gross_margin is not None else None + + related_target_prices = [float_or_none(item.get("targ_price")) for item in related_reports] + related_target_prices = [value for value in related_target_prices if value is not None] + related_target_price_median = float(median(related_target_prices)) if related_target_prices else None + + industry_info = next((item for item in industry_records if isinstance(item, dict) and normalize_ticker(item) == args.stock), None) + industry_text = " - ".join( + str(industry_info.get(field) or "") + for field in ("first_industry_name", "second_industry_name", "third_industry_name") + if industry_info and industry_info.get(field) + ) or "未提供" + listed_date = parse_iso_date((company_info or {}).get("listed_date")) + office_address = str((company_info or {}).get("office_address") or "未提供") + + trend_rows = [] + single_quarter_rows = [] + recent_snapshots = [build_snapshot(item) for item in deduped[-8:]] + for snapshot in reversed(recent_snapshots): + prev_for_snapshot = snapshot_map.get(previous_quarter(snapshot.quarter) or "") + yoy_for_snapshot = snapshot_map.get(yoy_quarter(snapshot.quarter) or "") + yoy_prev_for_snapshot = snapshot_map.get(previous_quarter(yoy_for_snapshot.quarter) or "") if yoy_for_snapshot else None + single_snapshot = to_single_quarter(snapshot, prev_for_snapshot) + yoy_single_snapshot = to_single_quarter(yoy_for_snapshot, yoy_prev_for_snapshot) if yoy_for_snapshot else QuarterDelta(None, None, None, None) + trend_rows.append( + [ + snapshot.quarter.upper(), + billion_yuan_text(snapshot.revenue), + billion_yuan_text(snapshot.net_profit), + percent_text(safe_growth(snapshot.revenue, yoy_for_snapshot.revenue if yoy_for_snapshot else None)), + percent_text(safe_growth(snapshot.net_profit, yoy_for_snapshot.net_profit if yoy_for_snapshot else None)), + ] + ) + single_quarter_rows.append( + [ + snapshot.quarter.upper(), + billion_yuan_text(single_snapshot.revenue), + billion_yuan_text(single_snapshot.net_profit), + percent_text(safe_growth(single_snapshot.revenue, yoy_single_snapshot.revenue)), + percent_text(safe_growth(single_snapshot.net_profit, yoy_single_snapshot.net_profit)), + ] + ) + + expectation_text = "符合预期" + if excess_3d is not None: + if excess_3d >= 3: + expectation_text = "正向反馈" + elif excess_3d <= -3: + expectation_text = "负向反馈" + + web_source_pairs = [(row["source_name"], row["confidence"]) for row in web_findings] + external_negative_findings = [row for row in web_findings if row["stance"] == "negative"] + external_context_rows = [ + [ + row["published_at"].isoformat(), + row["subject"], + WEB_FINDING_TYPE_LABELS.get(row["finding_type"], row["finding_type"]), + row["summary"], + source_confidence_label(row["source_name"], row["confidence"]), + ] + for row in web_findings[:6] + ] + external_context_lines = [ + f"- `{row['published_at'].isoformat()}` **{row['subject']}**:{trim_sentence_end(row['summary'])}。相关性:{trim_sentence_end(row['why_relevant'])}。来源:{row['source_name']}(置信度{row['confidence']},{markdown_link('链接', row['url'])})" + for row in web_findings[:6] + ] or ["- 当前未补充外部实时信息。"] + + if same_consensus_year: + consensus_exec_line = ( + f"- 财报前后 `{pre_consensus_year}` 年净利润一致预期为 {billion_yuan_text(profit_cons_pre)} -> " + f"{billion_yuan_text(profit_cons_post)},变化 {percent_text(profit_cons_delta)};一致目标价为 " + f"{yuan_price_text(target_price_pre)} -> {yuan_price_text(target_price_post)}。" + ) + revenue_expectation_line = ( + f"- 财报前后 `{pre_consensus_year}` 年收入一致预期为 {billion_yuan_text(revenue_cons_pre)} -> " + f"{billion_yuan_text(revenue_cons_post)},变化 {percent_text(revenue_cons_delta)}。" + ) + profit_expectation_line = ( + f"- 财报前后 `{pre_consensus_year}` 年净利润一致预期为 {billion_yuan_text(profit_cons_pre)} -> " + f"{billion_yuan_text(profit_cons_post)},变化 {percent_text(profit_cons_delta)}。" + ) + revenue_row_label = f"收入一致预期({pre_consensus_year}E)" + profit_row_label = f"净利润一致预期({pre_consensus_year}E)" + eps_row_label = f"EPS 一致预期({pre_consensus_year}E)" + else: + consensus_exec_line = ( + f"- 财报前 `{pre_consensus_year}` 年净利润一致预期为 {billion_yuan_text(profit_cons_pre)};财报后口径已滚动至 " + f"`{post_consensus_year}` 年,对应净利润一致预期为 {billion_yuan_text(profit_cons_post)};一致目标价为 " + f"{yuan_price_text(target_price_pre)} -> {yuan_price_text(target_price_post)}。" + ) + revenue_expectation_line = ( + f"- 财报前口径对应 `{pre_consensus_year}` 年收入一致预期 {billion_yuan_text(revenue_cons_pre)};财报后最新口径已滚动至 " + f"`{post_consensus_year}` 年,对应收入一致预期 {billion_yuan_text(revenue_cons_post)}。" + ) + profit_expectation_line = ( + f"- 财报前口径对应 `{pre_consensus_year}` 年净利润一致预期 {billion_yuan_text(profit_cons_pre)};财报后最新口径已滚动至 " + f"`{post_consensus_year}` 年,对应净利润一致预期 {billion_yuan_text(profit_cons_post)}。" + ) + revenue_row_label = f"收入一致预期({pre_consensus_year}E -> {post_consensus_year}E)" + profit_row_label = f"净利润一致预期({pre_consensus_year}E -> {post_consensus_year}E)" + eps_row_label = f"EPS 一致预期({pre_consensus_year}E -> {post_consensus_year}E)" + + info_panel = "\n".join( + [ + format_table( + ["维度", "当前值"], + [ + ["最新财报季度", latest_snapshot.quarter.upper()], + ["披露日", event_date.isoformat()], + ["行业", industry_text], + ["上市日期", listed_date.isoformat() if listed_date else "未提供"], + ["办公地址", office_address], + ["总市值", billion_yuan_text(market_cap)], + ["PE / PB / 股息率", f"{x_text(pe_ratio, 1)} / {x_text(pb_ratio)} / {percent_text(dividend_yield, signed=False)}"], + [f"财报前净利润一致预期({pre_consensus_year}E)", billion_yuan_text(profit_cons_pre)], + [f"财报后净利润一致预期({post_consensus_year}E)", billion_yuan_text(profit_cons_post)], + ["高度相关研报样本", f"{len(related_reports)} 条"], + ["相关公告样本", f"{len(selected_announcements)} 条"], + ], + ), + "", + "*数据来源:RQData,置信度5*", + ] + ) + + exec_summary = "\n".join( + [ + f"- 公司 **{company_name}**(`{args.stock}`)最新可分析财报季度为 `{latest_snapshot.quarter}`,披露日为 `{event_date.isoformat()}`,行业归属为 `{industry_text}`。", + f"- 累计口径收入 / 净利润为 {billion_yuan_text(latest_snapshot.revenue)} / {billion_yuan_text(latest_snapshot.net_profit)},同比 {percent_text(revenue_yoy)} / {percent_text(profit_yoy)},环比 {percent_text(revenue_qoq)} / {percent_text(profit_qoq)}。", + f"- 单季度口径收入 / 净利润为 {billion_yuan_text(latest_single.revenue)} / {billion_yuan_text(latest_single.net_profit)},同比 {percent_text(latest_single_revenue_yoy)} / {percent_text(latest_single_profit_yoy)}。", + f"- 毛利率 / 净利率 / 现金转化率 / 资产负债率分别为 {percent_text(gross_margin, signed=False)} / {percent_text(net_margin, signed=False)} / {x_text(cash_conversion)} / {percent_text(debt_ratio, signed=False)}。", + consensus_exec_line, + f"- 财报后 1D / 3D / 5D 个股收益为 {percent_text(price_1d)} / {percent_text(price_3d)} / {percent_text(price_5d)},3D 相对沪深300 超额收益为 {percent_text(excess_3d)};反馈标签为 `{expectation_text}`。", + f"- 相关卖方研报样本 {len(related_reports)} 条,相关公告样本 {len(selected_announcements)} 条。", + f"- 外部实时补充信息 {len(web_findings)} 条,主要用于补足管理层动态、业绩交流会、行业和政策语境。", + "", + format_data_source_line([("RQData", 5), *web_source_pairs]), + ] + ) + + earnings_overview = "\n".join( + [ + f"- 最新累计收入 / 净利润分别为 {billion_yuan_text(latest_snapshot.revenue)} / {billion_yuan_text(latest_snapshot.net_profit)},对应净利率 {percent_text(net_margin, signed=False)}。", + f"- 可比同期为 `{yoy_snapshot.quarter.upper() if yoy_snapshot else '无数据'}`,上一季度为 `{prev_snapshot.quarter.upper() if prev_snapshot else '无数据'}`。", + f"- 若拆为单季度口径,本期收入 / 净利润 / 经营现金流为 {billion_yuan_text(latest_single.revenue)} / {billion_yuan_text(latest_single.net_profit)} / {billion_yuan_text(latest_single.operating_cash)}。", + "", + format_table( + ["指标", latest_snapshot.quarter.upper(), yoy_snapshot.quarter.upper() if yoy_snapshot else "可比同期", "同比/变化", prev_snapshot.quarter.upper() if prev_snapshot else "上一季度", "环比/变化"], + [ + ["营业收入", billion_yuan_text(latest_snapshot.revenue), billion_yuan_text(yoy_snapshot.revenue if yoy_snapshot else None), percent_text(revenue_yoy), billion_yuan_text(prev_snapshot.revenue if prev_snapshot else None), percent_text(revenue_qoq)], + ["净利润", billion_yuan_text(latest_snapshot.net_profit), billion_yuan_text(yoy_snapshot.net_profit if yoy_snapshot else None), percent_text(profit_yoy), billion_yuan_text(prev_snapshot.net_profit if prev_snapshot else None), percent_text(profit_qoq)], + ["毛利率", percent_text(gross_margin, signed=False), percent_text(yoy_gross_margin, signed=False), percent_text(gross_margin_change), "无数据", "无数据"], + ["ROE", percent_text(latest_roe, signed=False), "无数据", "无数据", "无数据", "无数据"], + ], + ), + "", + "### 单季度口径复盘", + format_table( + ["指标", latest_snapshot.quarter.upper() + " 单季", (yoy_snapshot.quarter.upper() if yoy_snapshot else "可比同期") + " 单季", "同比/变化"], + [ + ["营业收入", billion_yuan_text(latest_single.revenue), billion_yuan_text(yoy_single.revenue), percent_text(latest_single_revenue_yoy)], + ["净利润", billion_yuan_text(latest_single.net_profit), billion_yuan_text(yoy_single.net_profit), percent_text(latest_single_profit_yoy)], + ["经营现金流", billion_yuan_text(latest_single.operating_cash), billion_yuan_text(yoy_single.operating_cash), percent_text(latest_single_cash_yoy)], + ["单季毛利率", percent_text(latest_single_gross_margin, signed=False), percent_text(yoy_single_gross_margin, signed=False), percent_text(latest_single_margin_change)], + ], + ), + "", + "### 预期完成度", + format_table( + ["口径", "财报前一致预期", "当前累计值", "完成度"], + [ + ["收入", billion_yuan_text(revenue_cons_pre), billion_yuan_text(latest_snapshot.revenue), percent_text(revenue_progress, signed=False)], + ["净利润", billion_yuan_text(profit_cons_pre), billion_yuan_text(latest_snapshot.net_profit), percent_text(profit_progress, signed=False)], + ["EPS", yuan_price_text(eps_cons_pre), yuan_price_text(eps_cons_post), percent_text(eps_cons_delta)], + ], + ), + "", + "### 近八个季度累计趋势", + format_table(["季度", "收入", "净利润", "收入同比", "净利润同比"], trend_rows), + "", + "### 近八个季度单季趋势", + format_table(["季度", "单季收入", "单季净利润", "单季收入同比", "单季净利润同比"], single_quarter_rows), + "", + "*数据来源:RQData,置信度5*", + ] + ) + + report_rows = [] + report_summary_lines = [] + for item in related_reports[:8]: + report_year = str(item.get("fiscal_year") or "") + net_profit_forecast = float_or_none(item.get("net_profit_t")) if report_year == str(target_year) else None + report_rows.append( + [ + str(item.get("create_tm") or item.get("create_time") or item.get("date") or "无数据")[:10], + str(item.get("institute") or "无数据"), + compact_text(item.get("report_title") or "无标题", 50), + billion_yuan_text(net_profit_forecast), + yuan_price_text(float_or_none(item.get("targ_price"))), + ] + ) + summary = compact_text(item.get("summary"), 180) + report_summary_lines.append( + f"- `{str(item.get('create_tm') or item.get('create_time') or item.get('date') or '')[:10]}` **{item.get('institute') or '无数据'}**:{item.get('report_title') or '无标题'}。摘要:{summary or '无摘要'}" + ) + if not report_rows: + report_rows = [["无高度相关研报", "-", "-", "-", "-"]] + report_summary_lines = ["- 报告期附近未识别到高度相关的公司财报点评。"] + + expectation_and_reaction = "\n".join( + [ + revenue_expectation_line, + profit_expectation_line, + f"- 财报前后一致目标价为 {yuan_price_text(target_price_pre)} -> {yuan_price_text(target_price_post)},变化 {percent_text(target_price_delta)};成交额中位数倍数为 {x_text(turnover_change)}。", + "", + format_table( + ["口径", "财报前", "财报后", "变化"], + [ + [revenue_row_label, billion_yuan_text(revenue_cons_pre), billion_yuan_text(revenue_cons_post), percent_text(revenue_cons_delta)], + [profit_row_label, billion_yuan_text(profit_cons_pre), billion_yuan_text(profit_cons_post), percent_text(profit_cons_delta)], + [eps_row_label, yuan_price_text(eps_cons_pre), yuan_price_text(eps_cons_post), percent_text(eps_cons_delta)], + ["一致目标价", yuan_price_text(target_price_pre), yuan_price_text(target_price_post), percent_text(target_price_delta)], + ], + ), + "", + "### 价格反馈看板", + format_table( + ["窗口", "个股收益", "基准收益", "超额收益", "成交额变化"], + [ + ["1D", percent_text(price_1d), percent_text(benchmark_1d), percent_text(excess_1d), x_text(turnover_change)], + ["3D", percent_text(price_3d), percent_text(benchmark_3d), percent_text(excess_3d), x_text(turnover_change)], + ["5D", percent_text(price_5d), percent_text(benchmark_5d), percent_text(excess_5d), x_text(turnover_change)], + ], + ), + "", + "### 卖方研报口径", + format_table(["日期", "机构", "标题", f"{target_year}E 净利润", "目标价"], report_rows), + "", + "### 研报摘要摘录", + *report_summary_lines, + "", + "### 外部实时补充信息", + format_table(["日期", "主题", "类型", "摘要", "来源/置信度"], external_context_rows or [["无数据", "-", "-", "-", "-"]]), + "", + *external_context_lines, + "", + format_data_source_line([("RQData", 5), *web_source_pairs]), + ] + ) + + announcement_rows = [] + announcement_detail_lines = [] + management_lines = [] + company_intro_lines = [] + risk_snippet_lines = [] + outlook_lines = [] + extract_status_notes = [] + has_annual_or_interim_extract_source = False + for item in selected_announcements: + link = str(item.get("announcement_link") or "") + extract_row = announcement_extract_index.get(link) or announcement_extract_index.get(str(item.get("title") or "")) + announcement_rows.append( + [ + str(item.get("info_date") or "无数据"), + compact_text(item.get("title") or "无标题", 42), + str(item.get("info_type") or "无数据"), + str(item.get("media") or "无数据"), + announcement_status_label(extract_row), + markdown_link("原文", link), + ] + ) + announcement_detail_lines.append( + f"- `{item.get('info_date') or '无数据'}` {item.get('title') or '无标题'};类型:{item.get('info_type') or '无数据'};来源:{item.get('media') or '无数据'};{markdown_link('原文', link)}" + ) + if extract_row: + if bool(extract_row.get("is_annual_or_interim_report")): + has_annual_or_interim_extract_source = True + company_intro = extract_section_display_text(extract_row, "company_intro", 220) + management_discussion = extract_section_display_text(extract_row, "management_discussion", 220) + risk_warning = extract_section_display_text(extract_row, "risk_warning", 220) + outlook = extract_section_display_text(extract_row, "outlook", 220) + if company_intro: + company_intro_lines.append(f"- `{item.get('title')}`:{company_intro}") + if management_discussion: + management_lines.append(f"- `{item.get('title')}`:{management_discussion}") + if risk_warning: + risk_snippet_lines.append(f"- `{item.get('title')}`:{risk_warning}") + if outlook: + outlook_lines.append(f"- `{item.get('title')}`:{outlook}") + fetch_status = str(extract_row.get("fetch_status") or "") + extract_status = str(extract_row.get("extract_status") or "") + if fetch_status and fetch_status not in ("ok", "skipped"): + extract_status_notes.append(f"- `{item.get('title')}`:正文未完整获取,报告保留原文链接供查阅。") + if extract_status and extract_status not in ("ok", "未提取", "skipped_non_annual_interim"): + extract_status_notes.append(f"- `{item.get('title')}`:正文未形成可直接引用片段,报告保留原文链接供查阅。") + if not announcement_rows: + announcement_rows = [["无相关公告", "-", "-", "-", "-", "-"]] + announcement_detail_lines = ["- 报告期附近未识别到与本次财报直接相关的公告。"] + if not company_intro_lines: + if announcement_extract_records and not has_annual_or_interim_extract_source: + company_intro_lines = ["- 本次相关公告以季报或临时公告为主,未单列公司介绍片段。"] + elif announcement_extract_records: + company_intro_lines = ["- 当前未形成可直接引用的公司介绍片段。"] + else: + company_intro_lines = ["- 当前未获取可直接引用的公司介绍片段。"] + if not management_lines: + if announcement_extract_records and not has_annual_or_interim_extract_source: + management_lines = ["- 本次相关公告以季报或临时公告为主,未单列管理层表述片段。"] + elif announcement_extract_records: + management_lines = ["- 当前未形成可直接引用的管理层表述片段。"] + else: + management_lines = ["- 当前未获取可直接引用的管理层表述片段。"] + if not risk_snippet_lines: + if announcement_extract_records and has_annual_or_interim_extract_source: + risk_snippet_lines = ["- 年报正文未提供可直接引用的风险提示片段。"] + else: + risk_snippet_lines = ["- 当前未获取可直接引用的风险提示片段。"] + if not outlook_lines: + if announcement_extract_records and not has_annual_or_interim_extract_source: + outlook_lines = ["- 本次相关公告以季报或临时公告为主,未单列经营展望片段。"] + elif announcement_extract_records: + outlook_lines = ["- 当前未形成可直接引用的经营展望片段。"] + else: + outlook_lines = ["- 当前未获取可直接引用的经营展望片段。"] + if not extract_status_notes and selected_announcements and not announcement_extract_records: + extract_status_notes = [ + "- 本节保留相关公告原文链接,供进一步查阅。" + ] + elif not extract_status_notes and announcement_extract_records: + if not has_annual_or_interim_extract_source: + extract_status_notes = [ + "- 相关公告以季报及临时公告为主,本节以公告链接和关键信息为主。" + ] + else: + extract_status_notes = [ + "- 本节优先引用年报或半年报正文中的管理层表述与经营展望,其他公告保留原文链接备查。" + ] + + announcement_section = "\n".join( + [ + format_table(["日期", "标题", "类型", "来源", "正文说明", "链接"], announcement_rows), + "", + "### 公告清单", + *announcement_detail_lines, + "", + "### 公司介绍片段", + *company_intro_lines, + "", + "### 管理层表述片段", + *management_lines, + "", + "### 风险提示片段", + *risk_snippet_lines, + "", + "### 经营展望片段", + *outlook_lines, + "", + "### 补充说明", + *extract_status_notes, + "", + "*数据来源:RQData(公告链接 / 可选原文提取),置信度5*", + ] + ) + + roe_rows = [] + for item in roe_records: + if not isinstance(item, dict) or normalize_ticker(item) != args.stock: + continue + event_day = parse_iso_date(item.get("date")) + roe_value = float_or_none(item.get("return_on_equity_weighted_average")) + if event_day is None or event_day > report_date or roe_value is None: + continue + roe_rows.append((event_day, roe_value)) + roe_rows = sorted(roe_rows, key=lambda pair: pair[0])[-5:] + roe_table_rows = [[row[0].isoformat(), percent_text(row[1], signed=False)] for row in roe_rows] or [["无数据", "无数据"]] + + financial_quality = "\n".join( + [ + f"- 经营现金流 / 现金转化率 / 总资产 / 总负债分别为 {billion_yuan_text(latest_snapshot.operating_cash)} / {x_text(cash_conversion)} / {billion_yuan_text(latest_snapshot.total_assets)} / {billion_yuan_text(latest_snapshot.total_liabilities)}。", + f"- 资产负债率 / 最新 ROE / 当前 PB 分别为 {percent_text(debt_ratio, signed=False)} / {percent_text(latest_roe, signed=False)} / {x_text(pb_ratio)}。", + "", + format_table( + ["维度", "当前值", "备注"], + [ + ["经营现金流", billion_yuan_text(latest_snapshot.operating_cash), "累计口径"], + ["现金转化率", x_text(cash_conversion), "经营现金流 / 净利润"], + ["总资产", billion_yuan_text(latest_snapshot.total_assets), "累计口径"], + ["总负债", billion_yuan_text(latest_snapshot.total_liabilities), "累计口径"], + ["资产负债率", percent_text(debt_ratio, signed=False), "总负债 / 总资产"], + ["最新 ROE", percent_text(latest_roe, signed=False), "最近非空因子日"], + ["当前 PB", x_text(pb_ratio), "最近非空因子日"], + ], + ), + "", + "### 近期 ROE 取值", + format_table(["日期", "ROE"], roe_table_rows), + "", + "*数据来源:RQData,置信度5*", + ] + ) + + growth_pillar = "通过" if (revenue_yoy is not None and revenue_yoy > 0 and profit_yoy is not None and profit_yoy > 0) else "未通过" + quality_pillar = "通过" if cash_conversion is not None and cash_conversion >= 1.0 and gross_margin_change is not None and gross_margin_change >= -1.0 else "未通过" + market_pillar = "通过" if excess_3d is not None and excess_3d >= 0 else "未通过" + announcement_pillar = "通过" if selected_announcements else "未通过" + external_pillar = "通过" if web_findings else "未通过" + thesis_update = "\n".join( + [ + format_table( + ["验证支柱", "结果", "证据"], + [ + ["增长口径", growth_pillar, f"收入同比 {percent_text(revenue_yoy)};净利润同比 {percent_text(profit_yoy)}"], + ["质量口径", quality_pillar, f"毛利率变动 {percent_text(gross_margin_change)};现金转化率 {x_text(cash_conversion)}"], + ["市场反馈", market_pillar, f"3D 超额收益 {percent_text(excess_3d)};成交额倍数 {x_text(turnover_change)}"], + ["公告链路", announcement_pillar, f"相关公告 {len(selected_announcements)} 条;正文读取结果 {len(announcement_extract_records)} 条"], + ["外部语境", external_pillar, f"外部补充信息 {len(web_findings)} 条"], + ], + ), + "", + "### 规则口径", + "- 增长口径:收入同比 > 0 且净利润同比 > 0。", + "- 质量口径:现金转化率 >= 1x 且毛利率同比变动 >= -1pct。", + "- 市场反馈:3D 超额收益 >= 0。", + "- 公告链路:报告期附近存在正式财报/主要经营数据/业绩说明会公告。", + "- 外部语境:只补充 RQData 无法直接提供的管理层动态、行业和政策背景,不替代财务与公告主源。", + "", + "### 外部语境摘录", + *external_context_lines[:4], + "", + format_data_source_line([("RQData", 5), *web_source_pairs]), + ] + ) + + valuation_section = "\n".join( + [ + f"- 当前市值约为 {billion_yuan_text(market_cap)},PE / PB / 股息率分别约为 {x_text(pe_ratio, 1)} / {x_text(pb_ratio)} / {percent_text(dividend_yield, signed=False)}。", + f"- 财报后一致目标价为 {yuan_price_text(target_price_post)};高度相关研报目标价中位数为 {yuan_price_text(related_target_price_median)}。", + "", + format_table( + ["指标", "当前值", "附注"], + [ + ["总市值", billion_yuan_text(market_cap), "最近非空因子日"], + ["PE", x_text(pe_ratio, 1), "最近非空因子日"], + ["PB", x_text(pb_ratio), "最近非空因子日"], + ["股息率", percent_text(dividend_yield, signed=False), "bps 已换算为百分比"], + ["财报前一致目标价", yuan_price_text(target_price_pre), "一致预期快照"], + ["财报后一致目标价", yuan_price_text(target_price_post), "一致预期快照"], + ["研报目标价中位数", yuan_price_text(related_target_price_median), "高度相关财报点评样本"], + ], + ), + "", + "*数据来源:RQData,置信度5*", + ] + ) + + risk_rows = [] + if profit_progress is not None and profit_progress < 75: + risk_rows.append(["全年兑现口径", f"净利润完成度 {percent_text(profit_progress, signed=False)}", "一致预期 vs 当前累计值", "RQData 财报 + consensus"]) + if gross_margin_change is not None and gross_margin_change < 0: + risk_rows.append(["盈利能力口径", f"毛利率同比变动 {percent_text(gross_margin_change)}", "累计毛利率对比", "RQData 财报"]) + if excess_3d is not None and excess_3d < 0: + risk_rows.append(["市场反馈口径", f"3D 超额收益 {percent_text(excess_3d)}", "财报后价格反应", "RQData 价格"]) + if profit_cons_delta is not None and profit_cons_delta < 0: + risk_rows.append(["预期修正口径", f"净利润一致预期变化 {percent_text(profit_cons_delta)}", "财报前后快照对比", "RQData consensus"]) + for row in external_negative_findings[:2]: + risk_rows.append( + [ + "外部实时口径", + compact_text(row["summary"], 60), + row["why_relevant"], + source_confidence_label(row["source_name"], row["confidence"]), + ] + ) + for line in risk_snippet_lines: + if line.startswith("- `"): + risk_rows.append(["原文风险提示", compact_text(line.replace("- ", ""), 60), "财报原文片段", "公告原文提取"]) + break + if not risk_rows: + risk_rows = [["未识别到新增显著风险", "无数据", "无数据", "RQData"]] + + risk_section = "\n".join( + [ + format_table(["风险维度", "当前证据", "口径", "来源"], risk_rows), + "", + format_data_source_line([("RQData", 5), *web_source_pairs]), + ] + ) + + appendix = "\n".join( + [ + f"- 报告日期:{args.report_date};公司:{company_name}({args.stock});最新财报季度:{latest_snapshot.quarter};披露日:{event_date.isoformat()}。", + f"- 行业:{industry_text};上市日期:{listed_date.isoformat() if listed_date else '未提供'};办公地址:{office_address}。", + f"- 一致预期口径使用财报披露前最后一条快照与报告日最新快照;本次读取的财报前年份为 {pre_consensus_year} 年,财报后滚动年份为 {post_consensus_year} 年。", + "- Q2、Q3、Q4 等累计披露口径已拆分为单季度口径,避免将累计值误读为单季变化。", + "- 股息率原始值为 bps,文中已统一换算为百分比。", + "- 公告章节优先引用年报或半年报正文片段,其他公告保留原文链接供查阅。", + f"- 外部实时补充信息:{len(web_findings)} 条;仅用于补充管理层动态、行业和政策语境,不替代 RQData 财务、预期和公告主源。", + "", + format_data_source_line([("RQData", 5), *web_source_pairs]), + ] + ) + + template_text = Path(args.template).read_text(encoding="utf-8") + report_text = render_template( + template_text, + { + "REPORT_DATE": args.report_date, + "COMPANY_NAME": company_name, + "STOCK_CODE": args.stock, + "LATEST_QUARTER": latest_snapshot.quarter.upper(), + "EVENT_DATE": event_date.isoformat(), + "EXEC_SUMMARY": exec_summary, + "INFO_PANEL": info_panel, + "EARNINGS_OVERVIEW": earnings_overview, + "EXPECTATION_AND_REACTION": expectation_and_reaction, + "ANNOUNCEMENT_SECTION": announcement_section, + "FINANCIAL_QUALITY": financial_quality, + "THESIS_UPDATE": thesis_update, + "VALUATION_SECTION": valuation_section, + "RISK_SECTION": risk_section, + "APPENDIX": appendix, + }, + ) + + output_path = Path(args.output).expanduser() if args.output else data_dir / f"earnings_analysis_{args.stock}_{args.report_date}.md" + 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() diff --git a/.cursor/skills/earnings-preview/SKILL.md b/.cursor/skills/earnings-preview/SKILL.md new file mode 100644 index 000000000..aee9159e8 --- /dev/null +++ b/.cursor/skills/earnings-preview/SKILL.md @@ -0,0 +1,465 @@ +--- +name: rq-earnings-preview +description: | + 创建模板驱动的财报预览报告,在财报发布前基于历史财务、近期股价、卖方一致预期、研报口径与网络搜索结果搭建可追踪的预判框架。 + `RQData CLI` 负责历史财务、价格、一致预期、研报与公告主数据;若需要补充目标季度预计披露日、电话会安排或近期行业动态,可额外使用 `web_search` 获取实时信息,并先落为结构化 JSON,再由 earnings-preview/scripts/generate_report.py 以客户可读方式纳入正文。 + + 务必使用此技能当用户: + - 明确请求财报预览、earnings preview、pre-earnings、财报前瞻 + - 想知道财报前看什么、哪些指标最关键 + - 需要结合市场预期和近期股价定位财报前 setup + - 需要在财报前形成一份结构化关注清单和情景分析 + + 不适用场景: + - 财报发布后的复盘分析 -> earnings-analysis + - 首次覆盖深度研究 -> initiating-coverage + - 只问一句“什么时候发财报” +compatibility: "Requires rqdata CLI, Bash, python3. Report structure is managed by earnings-preview/assets/template.md." +--- + +# RQ 股票研究 - 财报预览 + +## 核心原则 + +- 所有内容必须遵循三阶段流程:数据采集 -> 报告生成 -> HTML 渲染 +- `assets/template.md` 是唯一报告模板来源;Python 只做数据归一化、指标计算、占位符填充和结构校验 +- skill 必须自包含,不能依赖仓库级 `utils` +- 财报预览重在“预测框架”,不是把历史财务复述一遍 +- 报告必须显式区分“历史事实”“市场预期”“分析师估算” +- 市场预期应优先来自 `stock cn consensus` 与 `stock cn research-reports`,不得伪造 consensus +- 目标季度预计披露日、电话会和近期行业动态若需要写入正文,必须来自 `web_search`,不能靠训练记忆补写 +- 缺失数据时必须明确写“无数据 / 未提供 / 未验证”,不能留空 + +## 数据源分工 + +### `RQData CLI` 负责 + +- 公司信息、行业、历史财务、ROE、价格与成交额 +- 一致预期、研报、目标价和公告主数据 +- 历史股价反应、卖方分歧和预测锚点 + +### `web_search` 负责 + +- 目标季度预计披露日 +- 电话会 / 业绩会安排 +- 近期行业动态、政策变化和公司新闻 + +### `web_search` 禁止替代的内容 + +- 财务、价格、估值、公告、卖方预期和一致预期主数据 +- 情景分析的核心数值框架 +- 任何本应由 RQData 提供的结构化金融数据 + +## `web_search` 使用规则 + +详细字段、来源等级、落盘示例和 fallback 规则见 [references/web_search.md](references/web_search.md)。 + +允许补充的内容: + +- 目标季度预计披露日和电话会安排 +- 财报前近期行业与政策动态 +- 与本次财报预览直接相关的公司新闻 + +落盘要求: + +- 所有网络搜索结果必须先写入 `web_search_findings.json` +- 只写结构化记录,不把搜索草稿直接塞进报告 +- 若未提供该文件,报告仍可交付,但涉及时间安排和近期动态的部分必须保持“未验证”边界 +- 即便提供了 `web_search_findings.json`,它也只能补充预测背景,不能替代卖方预期与历史数据 + +## 硬性规则 + +以下任一条违反,视为输出失败: + +- `[MUST-1]` 历史财务数据、分析师预期、研报和公告必须来自 `RQData CLI` +- `[MUST-2]` 目标季度预计披露日必须通过 `web_search` 获取实时信息,不能使用训练记忆 +- `[MUST-3]` 金额类数据必须统一转换为“亿元”等客户可读口径 +- `[MUST-4]` `consensus` 的 `t1/t2/t3` 是年度 forward buckets,不得直接伪装成目标季度单季预测 +- `[MUST-5]` 情景分析必须包含乐观 / 中性 / 悲观三个情景及对应股价反应区间 +- `[MUST-6]` 研报若进入正文,必须优先消费 `summaries.core_view` 等客户可读摘要层 +- `[MUST-7]` 每个关键数据点或关键结论都要标 `数据来源:XXX,置信度X` +- `[MUST-8]` 客户稿不得暴露 `LLM`、`skill`、文件名、JSON 字段名或内部 workflow 术语 +- `[MUST-9]` 低置信度网络搜索结果不能单独支撑交易结论或发布日期结论 + +## 确信度评级 + +- `5`:RQData CLI、交易所公告、上市公司官网、官方监管披露 +- `4`:政府 / 监管 / 行业协会 / 官方机构、权威财经媒体 +- `3`:一般新闻源,但来源清晰且与其他来源一致 +- `2`:单一来源、细节不完整、时点未充分验证 +- `1`:推断、估算、未验证信息 + +使用规则: + +- 混合结论的置信度取关键来源中的最低等级 +- 预测与情景分析统一标注为“分析师估算,置信度4” +- 低置信度网络搜索结果只能作为时间安排或行业背景线索 + +## 图表 / 图片需求 + +当前实现以结构化表格完成最小可交付版本,但财报预览仍需明确图表 / 表格需求。 + +- 图表 / 表格名称:共识预测表 +- 目的:展示历史已披露季度与卖方年度预期口径 +- 使用的数据文件:`historical_financials.json`、`consensus.json`、`research_reports.json` +- 关键字段:`revenue`、`net_profit`、`con_targ_price`、`comp_con_*`、`net_profit_t` +- 建议形式:表格 +- 回答问题:市场当前预期高还是低、预期是否分歧 +- 放置位置:`## 卖方预期与市场定价` +- 若图表缺失:必须以表格保留 + +- 图表 / 表格名称:三情景预测表 +- 目的:展示乐观 / 中性 / 悲观三情景和预期市场反应 +- 使用的数据文件:`historical_financials.json`、`price_recent.json` +- 关键字段:`revenue`、`net_profit`、`gross_profit` / `profit_from_operation`(如有)或可回退的利润率口径、`close` +- 建议形式:表格 +- 回答问题:财报前风险收益比如何 +- 放置位置:`## 情景分析与市场反应` +- 若图表缺失:必须以表格保留 + +## 目标产出 + +- 报告长度:5-8 页 +- 输出文件: + - Markdown 报告 + - HTML 报告(若本地已安装渲染器) +- 输出目录必须由 `--data-dir` / `--output` 指定,不能写死固定路径 + +## 目录结构 + +```text +earnings-preview/ +├── SKILL.md +├── scripts/ +│ ├── extract_announcements.py +│ └── generate_report.py +├── assets/ +│ └── template.md +└── references/ + ├── data_contract.md + └── web_search.md +``` + +## 输入文件契约 + +原始数据目录由 `--data-dir` 指定,脚本会按下列文件名查找输入: + +- `company_info.json` +- `industry.json` +- `historical_financials.json` +- `roe_history.json` +- `price_recent.json` +- `hs300_recent.json` +- `consensus.json` +- `research_reports.json` +- `announcement_raw.json`:可选,建议提供 +- `announcement_extracts.json`:可选 +- `peers_financials.json`:可选 +- `peers_instruments.json`:可选 +- `web_search_findings.json`:可选,但若正文要写预计披露日 / 电话会 / 行业动态,则应提供 + +完整字段说明见 [references/data_contract.md](references/data_contract.md)。 + +## 工作流 + +### 步骤 1:准备参数 + +```bash +REPORT_DATE="${REPORT_DATE:-$(date +%F)}" +ORDER_BOOK_ID="${ORDER_BOOK_ID:-600519.XSHG}" +TARGET_QUARTER="${TARGET_QUARTER:-2026q1}" +PRICE_START_DATE="$(python3 - < "$DATA_DIR/company_info.json" + +rqdata stock cn industry --payload "{ + \"order_book_ids\": [\"$ORDER_BOOK_ID\"], + \"date\": \"$REPORT_DATE\" +}" --format json > "$DATA_DIR/industry.json" + +rqdata stock cn financial --payload "{ + \"order_book_ids\": [\"$ORDER_BOOK_ID\"], + \"fields\": [\"revenue\", \"net_profit\", \"gross_profit\", \"operating_expense\", \"cash_from_operating_activities\"], + \"start_quarter\": \"$HISTORY_START_QUARTER\", + \"end_quarter\": \"$HISTORY_END_QUARTER\", + \"statements\": \"all\" +}" --format json > "$DATA_DIR/historical_financials.json" + +rqdata stock cn financial-indicator --payload "{ + \"order_book_ids\": [\"$ORDER_BOOK_ID\"], + \"factor\": \"return_on_equity_weighted_average\", + \"start_date\": \"$ROE_START_DATE\", + \"end_date\": \"$REPORT_DATE\" +}" --format json > "$DATA_DIR/roe_history.json" +``` + +### 步骤 3:采集市场表现与预期数据 + +```bash +rqdata stock cn price --payload "{ + \"order_book_ids\": [\"$ORDER_BOOK_ID\"], + \"start_date\": \"$PRICE_START_DATE\", + \"end_date\": \"$REPORT_DATE\", + \"fields\": [\"close\", \"volume\", \"total_turnover\"], + \"adjust_type\": \"none\" +}" --format json > "$DATA_DIR/price_recent.json" + +rqdata index price --payload "{ + \"order_book_ids\": [\"000300.XSHG\"], + \"start_date\": \"$PRICE_START_DATE\", + \"end_date\": \"$REPORT_DATE\", + \"fields\": [\"close\"] +}" --format json > "$DATA_DIR/hs300_recent.json" + +rqdata stock cn consensus --payload "{ + \"order_book_ids\": [\"$ORDER_BOOK_ID\"], + \"start_date\": \"$REPORT_LOOKBACK_START\", + \"end_date\": \"$REPORT_DATE\", + \"report_range\": 3 +}" --format json > "$DATA_DIR/consensus.json" + +rqdata stock cn research-reports --payload "{ + \"order_book_ids\": [\"$ORDER_BOOK_ID\"], + \"fiscal_year\": \"$TARGET_FISCAL_YEAR\", + \"start_date\": \"$REPORT_LOOKBACK_START\", + \"end_date\": \"$REPORT_DATE\", + \"date_rule\": \"create_tm\" +}" --format json > "$DATA_DIR/research_reports_current_year.json" + +rqdata stock cn research-reports --payload "{ + \"order_book_ids\": [\"$ORDER_BOOK_ID\"], + \"fiscal_year\": \"$PREV_FISCAL_YEAR\", + \"start_date\": \"$REPORT_LOOKBACK_START\", + \"end_date\": \"$REPORT_DATE\", + \"date_rule\": \"create_tm\" +}" --format json > "$DATA_DIR/research_reports_prev_year.json" + +python3 - "$DATA_DIR/research_reports_prev_year.json" "$DATA_DIR/research_reports_current_year.json" "$DATA_DIR/research_reports.json" <<'PY' +import json +import sys +from pathlib import Path + +merged = [] +for path in sys.argv[1:3]: + payload = json.loads(Path(path).read_text()) + items = payload if isinstance(payload, list) else payload.get("data", []) + if isinstance(items, list): + merged.extend(items) +Path(sys.argv[3]).write_text(json.dumps(merged, ensure_ascii=False, indent=2), encoding="utf-8") +PY + +ANNOUNCEMENT_START="$(python3 - < "$DATA_DIR/announcement_raw.json" +``` + +### 步骤 3.5:网络搜索目标季度时间安排与近期行业动态 + +当正文需要写目标季度预计披露日、电话会或近期行业动态时,应执行该步骤。 + +- 使用 `web_search` 获取目标季度预计披露日 +- 可选补充电话会安排、近期行业动态和公司新闻 +- 结果必须写入 `web_search_findings.json` + +### 步骤 4:可选采集可比公司数据 + +若已有明确可比公司列表,可补充: + +```bash +if [ "$PEER_STOCKS_JSON" != "[]" ]; then + rqdata stock cn instruments --payload "{ + \"order_book_ids\": $PEER_STOCKS_JSON + }" --format json > "$DATA_DIR/peers_instruments.json" + + rqdata stock cn financial --payload "{ + \"order_book_ids\": $PEER_STOCKS_JSON, + \"fields\": [\"revenue\", \"net_profit\", \"gross_profit\"], + \"start_quarter\": \"$HISTORY_START_QUARTER\", + \"end_quarter\": \"$HISTORY_END_QUARTER\", + \"statements\": \"all\" + }" --format json > "$DATA_DIR/peers_financials.json" +fi +``` + +### 步骤 5:整理研报观点摘要(必须) + +说明: + +- 不新增额外摘要文件,直接在 `research_reports.json` 原记录上回写摘要 +- 摘要输入位置:`research_reports.json -> records[].summary` +- 摘要回写位置:`research_reports.json -> records[].summaries.core_view` +- 仅处理当前股票、且 `data_source=0` 的公司报告主样本 +- 摘要应为客户可读表述,压缩原始研报的核心判断、盈利预期变化、估值或关键观察点 +- 最终报告只消费这些摘要,不直接展示或截断原始 `summary` + +### 步骤 6:提取公告原文片段(可选但推荐) + +```bash +python3 earnings-preview/scripts/extract_announcements.py \ + --stock "$ORDER_BOOK_ID" \ + --data-dir "$DATA_DIR" \ + --report-date "$REPORT_DATE" +``` + +说明: + +- 该步骤会从近期正式财报、主要经营数据、业绩说明会等公告中提取可复用正文片段 +- `announcement_extracts.json` 采用两层结构: + - `raw_sections`:保存较长原文段落,仅作为后续总结输入 + - `summaries`:保存面向最终报告的精炼总结 +- `公司背景 / 管理层表述 / 经营展望` 主要针对年报、半年报正文;季报和临时公告保留原文链接为主 +- 若源站阻断或 PDF 不可读,也必须保留失败状态和原文链接 + +### 步骤 7:生成 Markdown 报告 + +```bash +python3 earnings-preview/scripts/generate_report.py \ + --stock "$ORDER_BOOK_ID" \ + --quarter "$TARGET_QUARTER" \ + --data-dir "$DATA_DIR" \ + --report-date "$REPORT_DATE" \ + --output "$OUTPUT_MD" +``` + +### 步骤 8:渲染 HTML + +脚本会优先尝试调用本地安装的 `rq-report-renderer`;若未安装则回退到仓库内 `report-renderer/scripts/render_report.py`;两者都不可用时才保留 Markdown 并打印警告。 + +## 阶段门控 + +### Gate 1:历史与预期主数据齐备 + +- 历史财务、股价、卖方预期主数据存在 +- 历史事实和市场预期可以明确分层 +- 即使没有网络搜索结果,也能形成基础财报预览框架 + +### Gate 2:预测框架完成 + +- 已识别最新已披露季度 +- 已形成目标季度基准预测 +- 三情景和股价反应区间可解释 + +### Gate 3:可选网络搜索结果完成 + +- 若启用网络搜索结果,`web_search_findings.json` 已落盘 +- 预计披露日和电话会若进入正文,来源等级可解释 +- 网络搜索结果只补充时间安排和行业背景,不越权替代主数据 + +### Gate 4:成稿完成 + +- Markdown 已生成 +- 若本地渲染器存在,HTML 已生成 +- 客户稿不暴露内部术语 +- 长度、章节、来源标注和预测框架达标 + +## 模板规则 + +- 报告必须严格基于 [template.md](assets/template.md) 生成 +- 占位符采用 `[[TOKEN]]` 语法,不使用 Jinja +- 当前模板仅允许以下占位符: + - `[[REPORT_DATE]]` + - `[[COMPANY_NAME]]` + - `[[STOCK_CODE]]` + - `[[TARGET_QUARTER]]` + - `[[EARNINGS_EVENT_CONTEXT]]` + - `[[EXEC_SUMMARY]]` + - `[[HISTORICAL_TREND]]` + - `[[FORECAST_FRAMEWORK]]` + - `[[EXPECTATION_SECTION]]` + - `[[ANNOUNCEMENT_SECTION]]` + - `[[SCENARIO_SECTION]]` + - `[[TRADING_SETUP]]` + - `[[RISK_SECTION]]` + - `[[APPENDIX]]` + +## 报告质量要求 + +- 完整包含模板中的主章节 +- 必须显式区分历史数据、卖方预期和分析师预测 +- 不能只写“市场预期高/低”,必须给出对应数据 +- 必须回答 4 个预测核心问题: + - 目标季度预计看什么 + - 市场当前预期是什么 + - 乐观 / 中性 / 悲观三情景分别长什么样 + - 财报前交易定位偏向抢跑、保守还是中性 +- 若正文写了预计披露日或电话会,必须来自网络搜索结果 +- 若提供公告数据,正文必须保留原文链接;若提供提炼结果,应优先展示管理层和经营展望片段 +- 近期研报样本若进入正文,必须展示整理后的客户可读摘要,不得直接截断原始 `summary` +- 不得残留 `[XX]`、`[需要补充]`、`[风险1]` 这类占位文本 + +## 阶段验收清单 + +- [ ] Markdown 文件存在 +- [ ] 若本地渲染器存在则 HTML 文件存在 +- [ ] 模板占位符无残留 +- [ ] 主章节完整 +- [ ] 预测章节和情景章节带有“分析师估算,置信度4” +- [ ] 市场预期章节确实引用了 `consensus.json` 或 `research_reports.json` +- [ ] 若存在相关公告,正文保留原文链接 +- [ ] 若启用 `web_search`,正文真实出现网络搜索结果而不是只多一个 sidecar JSON + +## 常见错误 + +- 把 `consensus` 当作季度预期直接使用,却没有说明它是 forward annual buckets +- 写“市场预期”“交易建议”却没有任何研报、目标价或价格行为依据 +- 目标季度预计披露日直接靠训练记忆补写 +- 使用固定季度、固定日期或固定同业名单 +- 把网络搜索结果直接写成主结论,反而压过 RQData 主数据 diff --git a/.cursor/skills/earnings-preview/assets/template.md b/.cursor/skills/earnings-preview/assets/template.md new file mode 100644 index 000000000..e2c282c8e --- /dev/null +++ b/.cursor/skills/earnings-preview/assets/template.md @@ -0,0 +1,33 @@ +# 财报预览报告 + +- 报告日期:[[REPORT_DATE]] +- 公司:[[COMPANY_NAME]](`[[STOCK_CODE]]`) +- 目标季度:`[[TARGET_QUARTER]]` +- 预计披露信息:[[EARNINGS_EVENT_CONTEXT]] + +## 执行摘要 +[[EXEC_SUMMARY]] + +## 预览季度预测框架 +[[FORECAST_FRAMEWORK]] + +## 卖方预期与市场定价 +[[EXPECTATION_SECTION]] + +## 公告与管理层线索 +[[ANNOUNCEMENT_SECTION]] + +## 情景分析与市场反应 +[[SCENARIO_SECTION]] + +## 财报前交易定位 +[[TRADING_SETUP]] + +## 关键风险与验证点 +[[RISK_SECTION]] + +## 历史经营与财务趋势 +[[HISTORICAL_TREND]] + +## 附录:口径说明 +[[APPENDIX]] diff --git a/.cursor/skills/earnings-preview/references/data_contract.md b/.cursor/skills/earnings-preview/references/data_contract.md new file mode 100644 index 000000000..ecb62bb39 --- /dev/null +++ b/.cursor/skills/earnings-preview/references/data_contract.md @@ -0,0 +1,265 @@ +# earnings-preview 数据契约 + +`earnings-preview/scripts/generate_report.py` 默认从 `--data-dir` 读取以下 JSON 文件。 + +## 1. `company_info.json` + +典型字段: + +- `order_book_id` +- `symbol` +- `listed_date` +- `sector_code_name` + +用途: + +- 获取公司名称、股票代码和基础元数据 + +## 2. `industry.json` + +典型字段: + +- `order_book_id` +- `first_industry_code` +- `first_industry_name` + +用途: + +- 补充行业信息 + +## 3. `historical_financials.json` + +典型字段: + +- `order_book_id` +- `quarter` +- `info_date` +- `revenue` +- `net_profit` +- `gross_profit` +- `profit_from_operation`(可选) +- `operating_expense` +- `cash_from_operating_activities` + +用途: + +- 历史趋势分析 +- 计算同比、利润率、费用率、现金转化率 +- 作为目标季度预测锚点 + +说明: + +- 若 `gross_profit` 可用,则优先使用毛利率 +- 若 `gross_profit` 缺失但 `profit_from_operation` 可用,则回退到营业利润率 +- 若两者都缺失,则回退到 `net_profit / revenue` 的净利率 +- 金融股、保险股等样本常见 `gross_profit` 缺失,不应因此整段利润率分析退化为“无数据” + +## 4. `roe_history.json` + +典型字段: + +- `order_book_id` +- `date` +- `return_on_equity_weighted_average` + +用途: + +- 分析资本效率趋势 + +## 5. `price_recent.json` + +典型字段: + +- `order_book_id` +- `datetime` +- `close` +- `volume` +- `total_turnover` + +用途: + +- 计算近 3 个月股价涨跌幅 +- 估算财报前 realized volatility +- 分析成交额变化 + +说明: + +- `close` 应使用未复权现价口径(例如 `adjust_type=none`) +- 若使用后复权价格,和卖方目标价直接比较会导致隐含空间失真 + +## 6. `hs300_recent.json` + +典型字段: + +- `order_book_id` +- `datetime` +- `close` + +用途: + +- 计算相对沪深300的超额收益 + +## 7. `consensus.json` + +典型字段: + +- `date` +- `create_tm` +- `con_targ_price` +- `comp_con_operating_revenue_t1` +- `comp_con_operating_revenue_t2` +- `comp_con_operating_revenue_t3` +- `comp_con_net_profit_t1` +- `comp_con_net_profit_t2` +- `comp_con_net_profit_t3` + +用途: + +- 获取最新一致预期快照 +- 作为 market expectations 的结构化参考 + +注意: + +- `t1/t2/t3` 是 RQData 提供的 forward buckets,不应直接假装成目标季度预测值 + +## 8. `research_reports.json` + +典型字段: + +- `date` +- `create_tm` +- `data_source` +- `report_title` +- `institute` +- `author` +- `summary` +- `summaries.core_view` +- `targ_price` +- `net_profit_t` +- `net_profit_t1` +- `revenue_t` +- `revenue_t1` + +用途: + +- 获取近期研报标题、摘要、目标价和年度利润口径 +- 形成卖方预期与市场定价章节 +- `summaries.core_view` 保存面向最终报告的精炼观点摘要 + +注意: + +- 若存在 `data_source`,应优先保留 `data_source=0` 的样本作为公司报告主样本 +- `data_source!=0` 的记录可能混入行业、策略或其他公司内容,不应直接进入近期研报样本表 +- 最终报告不应直接截断原始 `summary`;若要展示研报观点,应优先读取 `summaries.core_view` + +## 9. `peers_financials.json` / `peers_instruments.json` + +可选文件。 + +用途: + +- 若已提供可比公司列表,可对比最新收入、利润和可用利润率水平 + +## 10. `announcement_raw.json` + +可选文件。 + +典型字段: + +- `info_date` +- `title` +- `info_type` +- `media` +- `file_type` +- `announcement_link` + +用途: + +- 保留近期正式财报、主要经营数据公告、业绩说明会等原文链接 +- 为财报前预览补充管理层近期沟通与经营线索 + +## 11. `announcement_extracts.json` + +可选文件,可由 `earnings-preview/scripts/extract_announcements.py` 生成。 + +典型字段: + +- `records[].title` +- `records[].info_date` +- `records[].announcement_link` +- `records[].is_annual_or_interim_report` +- `records[].fetch_status` +- `records[].extract_status` +- `records[].raw_sections.company_intro` +- `records[].raw_sections.management_discussion` +- `records[].raw_sections.risk_warning` +- `records[].raw_sections.outlook` +- `records[].summaries.company_intro` +- `records[].summaries.management_discussion` +- `records[].summaries.risk_warning` +- `records[].summaries.outlook` + +用途: + +- `raw_sections` 保存较长原文段落,供后续总结使用 +- `summaries` 保存可直接进入最终报告的精炼片段 +- `company_intro` / `management_discussion` / `outlook` 主要面向年报、半年报正文;季报和临时公告默认以链接留痕为主 +- 若源站拦截或 PDF 不可读,也必须保留失败状态和原文链接 + +## 解析约定 + +- 所有文件都允许 `{"data": [...]}`、`{"data": {...}}`、`[...]`、`{...}` 四种包装方式 +- 同一股票同一季度若存在多条财务记录,脚本会按 `info_date` 选择最新披露版本 +- 若卖方预期文件缺失,报告必须明确说明“未提供卖方口径数据” +- 若公告提炼文件缺失,报告仍应保留相关公告原文链接 + +## 12. `web_search_findings.json` + +可选文件。 + +典型字段: + +- `query` +- `source_name` +- `source_type` +- `title` +- `url` +- `published_at` +- `retrieved_at` +- `summary` +- `why_relevant` +- `confidence` +- `finding_type` +- `event_date`(可选) +- `expected_window`(可选) + +用途: + +- 补充目标季度预计披露日与电话会安排 +- 补充近期行业动态和公司新闻 +- 为财报预览提供时间窗口与前瞻背景,但不替代财务、价格和一致预期主数据 + +允许的 `finding_type`: + +- `earnings_release_date` +- `conference_call` +- `industry_context` +- `company_news` + +允许的 `source_type`: + +- `official` +- `government` +- `association` +- `authoritative_media` +- `general_news` +- `inference` + +置信度上限: + +- `official`: `5` +- `government`: `4` +- `association`: `4` +- `authoritative_media`: `4` +- `general_news`: `3` +- `inference`: `1` diff --git a/.cursor/skills/earnings-preview/references/web_search.md b/.cursor/skills/earnings-preview/references/web_search.md new file mode 100644 index 000000000..6f3171875 --- /dev/null +++ b/.cursor/skills/earnings-preview/references/web_search.md @@ -0,0 +1,100 @@ +# Earnings Preview Web Search Reference + +## Purpose + +Use `web_search` only to supplement earnings release date, conference-call arrangement, recent company developments, and industry context that `RQData CLI` does not directly provide for an earnings-preview report. + +## Allowed Coverage + +- Expected earnings release date for the target quarter +- Conference call / results briefing time and registration details +- Recent industry trend or policy changes relevant to the upcoming quarter +- Recent company news that may affect the quarter preview or guidance discussion + +## Prohibited Usage + +- Do not replace historical financials, prices, consensus data, research reports, or announcements +- Do not fabricate release dates, conference calls, or guidance +- Do not let low-confidence network-search findings replace the structured prediction framework + +## Required Output File + +All network-search findings must be written to `web_search_findings.json`. + +Each record must contain: + +- `query` +- `source_name` +- `source_type` +- `title` +- `url` +- `published_at` +- `retrieved_at` +- `summary` +- `why_relevant` +- `confidence` +- `finding_type` + +Recommended fields: + +- `subject` +- `related_entities` +- `event_date` +- `expected_window` + +## Allowed `finding_type` + +- `earnings_release_date` +- `conference_call` +- `industry_context` +- `company_news` + +## Source Types And Confidence Ceiling + +- `official`: max confidence `5` +- `government`: max confidence `4` +- `association`: max confidence `4` +- `authoritative_media`: max confidence `4` +- `general_news`: max confidence `3` +- `inference`: max confidence `1` + +## Search Workflow + +1. Confirm the needed information is not directly available from `RQData CLI`. +2. For release dates and conference calls, prefer exchange, company IR, and official announcement sources first. +3. Save the findings into `web_search_findings.json` with structured metadata. +4. Keep summaries factual and tie the relevance note to the quarter preview, guidance risk, or timing risk. +5. Use the findings only to supplement the prediction framework, not to replace it. + +## 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 dates or call arrangements + - explicitly mark the relevant timing information as unavailable or unverified + - keep the report at the structured preview level + +## Example + +```json +{ + "data": [ + { + "query": "贵州茅台 2026Q1 业绩发布日期", + "source_name": "上交所", + "source_type": "official", + "title": "定期报告预约披露日公告", + "url": "https://www.example.com/sse", + "published_at": "2026-04-10", + "retrieved_at": "2026-04-11", + "summary": "公司披露 2026Q1 定期报告预约披露日。", + "why_relevant": "可用于确定财报前窗口和交易节奏安排。", + "confidence": 5, + "finding_type": "earnings_release_date", + "event_date": "2026-04-28", + "subject": "定期报告预约披露日" + } + ] +} +``` diff --git a/.cursor/skills/earnings-preview/scripts/extract_announcements.py b/.cursor/skills/earnings-preview/scripts/extract_announcements.py new file mode 100644 index 000000000..40a0d07f8 --- /dev/null +++ b/.cursor/skills/earnings-preview/scripts/extract_announcements.py @@ -0,0 +1,666 @@ +#!/usr/bin/env python3 +"""Extract structured announcement snippets for earnings-preview.""" + +from __future__ import annotations + +import argparse +import json +import re +import zlib +from datetime import date +from pathlib import Path +from typing import Any, Dict, List, Optional, Sequence, Tuple + +import requests + +from generate_report import ( + dedupe_financial_records, + extract_records, + parse_iso_date, + read_json_file, + select_relevant_announcements, +) + +USER_AGENT = ( + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" +) +ACW_POS_LIST = [ + 0x0F, + 0x23, + 0x1D, + 0x18, + 0x21, + 0x10, + 0x01, + 0x26, + 0x0A, + 0x09, + 0x13, + 0x1F, + 0x28, + 0x1B, + 0x16, + 0x17, + 0x19, + 0x0D, + 0x06, + 0x0B, + 0x27, + 0x12, + 0x14, + 0x08, + 0x0E, + 0x15, + 0x20, + 0x1A, + 0x02, + 0x1E, + 0x07, + 0x04, + 0x11, + 0x05, + 0x03, + 0x1C, + 0x22, + 0x25, + 0x0C, + 0x24, +] +ACW_MASK = "3000176000856006061501533003690027800375" +OBJ_RE = re.compile(rb"(\d+)\s+(\d+)\s+obj\b(.*?)endobj", re.S) +STREAM_RE = re.compile(rb"<<(.*?)>>\s*stream\r?\n(.*?)\r?\nendstream", re.S) +PAGE_RE = re.compile(rb"/Type\s*/Page\b") +TEXT_OP_RE = re.compile( + r"/([A-Za-z0-9]+)\s+[0-9.]+\s+Tf|" + r"<([0-9A-Fa-f\s]+)>\s*Tj|" + r"\[(.*?)\]\s*TJ|" + r"\(((?:\\.|[^\\)])*)\)\s*Tj|" + r"(-?[0-9.]+)\s+(-?[0-9.]+)\s+T[Dd]|" + r"T\*|BT|ET", + re.S, +) +TEXT_SECTION_STOP_MARKERS = [ + "重要内容提示", + "一、主要财务数据", + "二、股东信息", + "三、其他提醒事项", + "四、季度财务报表", + "五、重要事项", + "六、其他事项", + "风险提示", + "重大风险提示", + "经营情况讨论与分析", + "管理层讨论与分析", + "投资者关系活动主要内容介绍", + "未来展望", + "经营计划", + "发展战略", +] + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="提取公告 PDF 正文片段并生成 announcement_extracts.json") + parser.add_argument("--stock", required=True, help="股票代码") + parser.add_argument("--quarter", required=True, help="目标预览季度,例如 2026q1") + parser.add_argument("--data-dir", required=True, help="原始 JSON 数据目录") + parser.add_argument("--report-date", required=True, help="报告日期 (YYYY-MM-DD)") + parser.add_argument("--output", help="输出 JSON 路径,默认写到 data-dir/announcement_extracts.json") + parser.add_argument("--timeout", type=float, default=20.0, help="公告抓取超时时间,默认 20 秒") + return parser.parse_args() + + +def calc_sse_acw_cookie(arg1: str) -> str: + out = [""] * len(ACW_POS_LIST) + for idx, char in enumerate(arg1): + for out_idx, pos in enumerate(ACW_POS_LIST): + if pos == idx + 1: + out[out_idx] = char + break + arg2 = "".join(out) + pieces = [] + for idx in range(0, min(len(arg2), len(ACW_MASK)), 2): + pieces.append(f"{int(arg2[idx:idx + 2], 16) ^ int(ACW_MASK[idx:idx + 2], 16):02x}") + return "".join(pieces) + + +def fetch_pdf_bytes(url: str, timeout: float) -> Tuple[Optional[bytes], str]: + referer = "https://www.cninfo.com.cn/" + if "sse.com.cn" in url: + referer = "http://www.sse.com.cn/" + elif "szse.cn" in url: + referer = "https://www.szse.cn/" + headers = {"User-Agent": USER_AGENT, "Accept": "application/pdf,text/html,*/*", "Referer": referer} + session = requests.Session() + + try: + response = session.get(url, timeout=timeout, headers=headers, allow_redirects=True) + except requests.RequestException as exc: + return None, f"network_error:{type(exc).__name__}" + + content_type = (response.headers.get("content-type") or "").lower() + if response.ok and (content_type.startswith("application/pdf") or response.content.startswith(b"%PDF-")): + return response.content, "ok" + + if "static.sse.com.cn" in response.url and "text/html" in content_type: + match = re.search(r"arg1='([^']+)'", response.text) + if not match: + return None, "source_blocked:sse_html_without_arg1" + cookie = calc_sse_acw_cookie(match.group(1)) + session.cookies.set("acw_sc__v2", cookie, domain="static.sse.com.cn", path="/") + try: + retry = session.get( + url, + timeout=timeout, + headers={"User-Agent": USER_AGENT, "Accept": "application/pdf,*/*", "Referer": "http://www.sse.com.cn/"}, + allow_redirects=True, + ) + except requests.RequestException as exc: + return None, f"network_error:{type(exc).__name__}" + retry_type = (retry.headers.get("content-type") or "").lower() + if retry.ok and (retry_type.startswith("application/pdf") or retry.content.startswith(b"%PDF-")): + return retry.content, "ok" + return None, f"source_blocked:sse_retry_{retry.status_code}" + + if not response.ok: + return None, f"http_{response.status_code}" + return None, f"unsupported_content_type:{content_type or 'unknown'}" + + +def parse_pdf_objects(pdf_bytes: bytes) -> Dict[int, bytes]: + return {int(match.group(1)): match.group(3) for match in OBJ_RE.finditer(pdf_bytes)} + + +def parse_stream(raw_object: bytes) -> Tuple[Optional[bytes], Optional[bytes]]: + match = STREAM_RE.search(raw_object) + if not match: + return None, None + stream_dict = match.group(1) + stream_data = match.group(2) + if b"/FlateDecode" in stream_dict: + stream_data = zlib.decompress(stream_data) + return stream_dict, stream_data + + +def decode_utf16be_hex(value: str) -> str: + return bytes.fromhex(value).decode("utf-16-be", "ignore") + + +def build_cmap(stream_text: str) -> Dict[str, str]: + cmap: Dict[str, str] = {} + for block in re.findall(r"beginbfchar\s*(.*?)\s*endbfchar", stream_text, re.S): + for src, dst in re.findall(r"<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>", block): + cmap[src.upper()] = decode_utf16be_hex(dst) + for block in re.findall(r"beginbfrange\s*(.*?)\s*endbfrange", stream_text, re.S): + for start, end, dst in re.findall(r"<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>", block): + start_int = int(start, 16) + end_int = int(end, 16) + dst_int = int(dst, 16) + width = len(start) + out_len = len(dst) // 2 + for idx, code in enumerate(range(start_int, end_int + 1)): + cmap[f"{code:0{width}X}"] = (dst_int + idx).to_bytes(out_len, "big").decode("utf-16-be", "ignore") + for start, _end, arr in re.findall(r"<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>\s*\[(.*?)\]", block, re.S): + start_int = int(start, 16) + width = len(start) + for idx, dst in enumerate(re.findall(r"<([0-9A-Fa-f]+)>", arr)): + cmap[f"{start_int + idx:0{width}X}"] = decode_utf16be_hex(dst) + return cmap + + +def decode_pdf_hex(hex_text: str, cmap: Dict[str, str]) -> str: + hex_text = re.sub(r"\s+", "", hex_text) + if not hex_text: + return "" + key_lengths = sorted({len(key) for key in cmap}, reverse=True) if cmap else [2] + cursor = 0 + output: List[str] = [] + while cursor < len(hex_text): + matched = False + for width in key_lengths: + key = hex_text[cursor:cursor + width].upper() + if len(key) == width and key in cmap: + output.append(cmap[key]) + cursor += width + matched = True + break + if matched: + continue + chunk = hex_text[cursor:cursor + 2] + if len(chunk) == 2: + try: + output.append(bytes.fromhex(chunk).decode("latin1")) + except ValueError: + pass + cursor += 2 + return "".join(output) + + +def decode_pdf_literal(text: str) -> str: + return ( + text.replace(r"\(", "(") + .replace(r"\)", ")") + .replace(r"\n", "\n") + .replace(r"\r", "") + .replace(r"\t", "\t") + .replace(r"\\", "\\") + ) + + +def extract_pdf_text(pdf_bytes: bytes) -> str: + objects = parse_pdf_objects(pdf_bytes) + font_cmaps: Dict[int, Dict[str, str]] = {} + for obj_num, raw_object in objects.items(): + match = re.search(rb"/ToUnicode\s+(\d+)\s+0\s+R", raw_object) + if not match: + continue + stream_ref = int(match.group(1)) + if stream_ref not in objects: + continue + _stream_dict, stream_data = parse_stream(objects[stream_ref]) + if not stream_data: + continue + font_cmaps[obj_num] = build_cmap(stream_data.decode("latin1", "ignore")) + + pages: List[Tuple[int, List[int], Dict[str, int]]] = [] + for obj_num, raw_object in objects.items(): + if not PAGE_RE.search(raw_object): + continue + content_refs = [int(value) for value in re.findall(rb"/Contents\s+(\d+)\s+0\s+R", raw_object)] + if not content_refs: + array_match = re.search(rb"/Contents\s*\[(.*?)\]", raw_object, re.S) + if array_match: + content_refs = [int(value) for value in re.findall(rb"(\d+)\s+0\s+R", array_match.group(1))] + font_map: Dict[str, int] = {} + font_block = re.search(rb"/Font\s*<<(.+?)>>", raw_object, re.S) + if font_block: + for font_name, font_ref in re.findall(rb"/([A-Za-z0-9]+)\s+(\d+)\s+0\s+R", font_block.group(1)): + font_map[font_name.decode("ascii", "ignore")] = int(font_ref) + pages.append((obj_num, content_refs, font_map)) + pages.sort(key=lambda item: item[0]) + + lines: List[str] = [] + current_font: Optional[str] = None + for _page_num, content_refs, font_map in pages: + for content_ref in content_refs: + if content_ref not in objects: + continue + _stream_dict, stream_data = parse_stream(objects[content_ref]) + if not stream_data: + continue + content_text = stream_data.decode("latin1", "ignore") + current_line: List[str] = [] + for match in TEXT_OP_RE.finditer(content_text): + token = match.group(0) + if " Tf" in token: + current_font = match.group(1) + continue + if token == "BT": + current_line = [] + continue + if token == "ET": + line = "".join(current_line).strip() + if line: + lines.append(line) + current_line = [] + continue + if token == "T*" or token.endswith("TD") or token.endswith("Td"): + if match.group(6) and abs(float(match.group(6))) > 1e-6: + line = "".join(current_line).strip() + if line: + lines.append(line) + current_line = [] + continue + if token.endswith("Tj") and token.startswith("<"): + font_ref = font_map.get(current_font or "") + cmap = font_cmaps.get(font_ref, {}) + current_line.append(decode_pdf_hex(match.group(2), cmap)) + continue + if token.endswith("TJ"): + font_ref = font_map.get(current_font or "") + cmap = font_cmaps.get(font_ref, {}) + segment = match.group(3) or "" + for hex_group in re.findall(r"<([0-9A-Fa-f\s]+)>", segment): + current_line.append(decode_pdf_hex(hex_group, cmap)) + for literal in re.findall(r"\(((?:\\.|[^\\)])*)\)", segment): + current_line.append(decode_pdf_literal(literal)) + continue + current_line.append(decode_pdf_literal(match.group(4))) + + text = "\n".join(line for line in lines if line.strip()) + text = text.replace("\r", "\n").replace("\u3000", "") + text = re.sub(r"[ \t]+\n", "\n", text) + text = re.sub(r"\n{3,}", "\n\n", text) + return text.strip() + + +def squash_text(text: str) -> str: + return re.sub(r"\s+", "", text or "") + + +def clip_text(text: str, limit: int = 260) -> str: + text = str(text or "").strip() + if len(text) <= limit: + return text + return text[: limit - 1].rstrip(",、;: ") + "…" + + +def normalize_section_text(text: str, limit: Optional[int] = None) -> str: + text = str(text or "") + text = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f]", "", text) + text = re.sub(r"\s+", "", text) + if not text: + return "" + meaningful_chars = re.findall(r"[\u4e00-\u9fffA-Za-z0-9,。!?;:、“”‘’()()\-%./]", text) + if len(meaningful_chars) < max(20, int(len(text) * 0.6)): + return "" + if not re.search(r"[\u4e00-\u9fffA-Za-z]", text): + return "" + if limit is None: + return text + return clip_text(text, limit) + + +def is_annual_or_interim_report(title: str, info_type: str) -> bool: + title = str(title or "") + info_type = str(info_type or "") + if not re.search(r"(年度报告|年报|半年度报告|半年报|中报)", title): + return False + if re.search(r"(摘要|英文版|公告|业绩说明会|主要经营数据|信息披露公告)", title): + return False + return "定期报告" in info_type or bool(re.search(r"(年度报告|年报|半年度报告|半年报|中报)", title)) + + +def find_marker_window( + text: str, + markers: Sequence[str], + stop_markers: Sequence[str], + max_chars: int, + forbidden_patterns: Sequence[str] = (), +) -> str: + candidates: List[Tuple[int, str]] = [] + for marker in markers: + start = 0 + while True: + idx = text.find(marker, start) + if idx < 0: + break + candidates.append((idx, marker)) + start = idx + len(marker) + if not candidates: + return "" + + candidates.sort(key=lambda item: item[0]) + for best_start, matched_marker in candidates: + local_context = text[max(0, best_start - 80): min(len(text), best_start + 120)] + if re.search(r"[..。…]{12,}", local_context): + continue + search_start = best_start + len(matched_marker) + end_positions = [ + text.find(stop_marker, search_start) + for stop_marker in stop_markers + if stop_marker not in markers and text.find(stop_marker, search_start) >= 0 + ] + end = min(end_positions) if end_positions else min(len(text), best_start + max_chars) + end = min(end, best_start + max_chars) + snippet = normalize_section_text(text[best_start:end], max_chars) + if snippet and forbidden_patterns and any(pattern in snippet for pattern in forbidden_patterns): + continue + if snippet: + return snippet + return "" + + +def find_sentence_by_keywords(text: str, keywords: Sequence[str], max_chars: int) -> str: + sentences = re.split(r"(?<=[。!?;])", text) + for sentence in sentences: + sentence = sentence.strip() + if sentence and any(keyword in sentence for keyword in keywords): + return normalize_section_text(sentence, max_chars) + collapsed = text + for keyword in keywords: + idx = collapsed.find(keyword) + if idx >= 0: + start = max(0, idx - 40) + end = min(len(collapsed), idx + max_chars) + return normalize_section_text(collapsed[start:end], max_chars) + return "" + + +def extract_sections(title: str, info_type: str, raw_text: str) -> Dict[str, str]: + squashed = squash_text(raw_text) + stop_markers = TEXT_SECTION_STOP_MARKERS + long_form_report = is_annual_or_interim_report(title, info_type) + + company_intro = "" + management_discussion = "" + outlook = "" + if long_form_report: + intro_end = len(squashed) + for marker in ("重要内容提示", "一、主要财务数据"): + idx = squashed.find(marker) + if idx >= 0: + intro_end = min(intro_end, idx) + company_intro = normalize_section_text(squashed[:intro_end] or squashed[:220], 220) + company_intro_marked = find_marker_window( + squashed, + ["公司简介", "公司基本情况", "发行人基本情况"], + stop_markers, + 220, + ) + if company_intro_marked: + company_intro = company_intro_marked + + management_discussion = find_marker_window( + squashed, + [ + "管理层讨论与分析", + "经营情况讨论与分析", + "董事会报告", + "经营回顾", + ], + stop_markers, + 280, + ) + if not management_discussion: + management_discussion = find_sentence_by_keywords( + squashed, + ["经营", "销量", "需求", "增长", "盈利能力", "毛利率", "渠道", "产能"], + 240, + ) + + outlook = find_marker_window( + squashed, + ["未来展望", "经营计划", "发展战略", "未来规划", "下半年展望", "后续规划"], + stop_markers, + 220, + forbidden_patterns=("前瞻性陈述", "注意投资风险"), + ) + if not outlook: + outlook = find_sentence_by_keywords( + squashed, + ["未来", "展望", "预计", "计划", "规划", "目标", "将继续", "后续"], + 220, + ) + + risk_warning = find_marker_window( + squashed, + ["风险提示", "重大风险提示", "风险因素", "重大风险"], + stop_markers, + 220, + ) + + return { + "company_intro": company_intro, + "management_discussion": management_discussion, + "risk_warning": risk_warning, + "outlook": outlook, + } + + +def build_raw_sections(title: str, info_type: str, raw_text: str) -> Dict[str, str]: + squashed = squash_text(raw_text) + stop_markers = TEXT_SECTION_STOP_MARKERS + long_form_report = is_annual_or_interim_report(title, info_type) + + company_intro = "" + management_discussion = "" + outlook = "" + if long_form_report: + intro_end = len(squashed) + for marker in ("重要内容提示", "一、主要财务数据"): + idx = squashed.find(marker) + if idx >= 0: + intro_end = min(intro_end, idx) + company_intro = normalize_section_text(squashed[:intro_end] or squashed[:1200], 1200) + company_intro_marked = find_marker_window( + squashed, + ["公司简介", "公司基本情况", "发行人基本情况"], + stop_markers, + 1400, + ) + if company_intro_marked: + company_intro = company_intro_marked + + management_discussion = find_marker_window( + squashed, + ["管理层讨论与分析", "经营情况讨论与分析", "董事会报告", "经营回顾"], + stop_markers, + 2600, + ) + if not management_discussion: + management_discussion = find_sentence_by_keywords( + squashed, + ["经营", "销量", "需求", "增长", "盈利能力", "毛利率", "渠道", "产能"], + 1600, + ) + + outlook = find_marker_window( + squashed, + ["未来展望", "经营计划", "发展战略", "未来规划", "下半年展望", "后续规划"], + stop_markers, + 1800, + forbidden_patterns=("前瞻性陈述", "注意投资风险"), + ) + if not outlook: + outlook = find_sentence_by_keywords( + squashed, + ["未来", "展望", "预计", "计划", "规划", "目标", "将继续", "后续"], + 1200, + ) + + risk_warning = find_marker_window( + squashed, + ["风险提示", "重大风险提示", "风险因素", "重大风险"], + stop_markers, + 1400, + ) + + return { + "company_intro": company_intro, + "management_discussion": management_discussion, + "risk_warning": risk_warning, + "outlook": outlook, + } + + +def choose_extract_status(sections: Dict[str, str], title: str, info_type: str) -> str: + populated = sum(1 for value in sections.values() if value) + if not is_annual_or_interim_report(title, info_type) and populated == 0: + return "skipped_non_annual_interim" + if populated >= 4: + return "ok" + if populated >= 1: + return "partial" + return "no_sections" + + +def main() -> None: + args = parse_args() + report_date = date.fromisoformat(args.report_date) + data_dir = Path(args.data_dir).expanduser() + output_path = Path(args.output).expanduser() if args.output else data_dir / "announcement_extracts.json" + + financial_records = extract_records(read_json_file(data_dir / "historical_financials.json")) + announcement_records = extract_records(read_json_file(data_dir / "announcement_raw.json")) + if not financial_records: + raise ValueError("缺少 historical_financials.json,无法定位最近已披露季度") + + deduped_financials = dedupe_financial_records(financial_records, args.stock, report_date) + latest_financial = deduped_financials[-1] if deduped_financials else None + latest_info_date = parse_iso_date(latest_financial.get("info_date")) if latest_financial else None + + selected_announcements = select_relevant_announcements( + announcement_records, + args.stock, + report_date, + args.quarter.lower(), + ) + + records: List[Dict[str, Any]] = [] + for item in selected_announcements: + title = str(item.get("title") or "") + link = str(item.get("announcement_link") or "") + info_date = parse_iso_date(item.get("info_date") or item.get("date") or item.get("create_tm")) + empty_sections = { + "company_intro": "", + "management_discussion": "", + "risk_warning": "", + "outlook": "", + } + record: Dict[str, Any] = { + "title": title, + "info_date": info_date.isoformat() if info_date else str(item.get("info_date") or ""), + "announcement_link": link, + "media": item.get("media"), + "info_type": item.get("info_type"), + "is_annual_or_interim_report": is_annual_or_interim_report(title, str(item.get("info_type") or "")), + "fetch_status": "skipped", + "extract_status": "not_started", + "raw_sections": dict(empty_sections), + "summaries": dict(empty_sections), + "sections": dict(empty_sections), + } + + if str(item.get("file_type") or "").upper() != "PDF": + record["fetch_status"] = "unsupported_file_type" + record["extract_status"] = "unsupported" + records.append(record) + continue + + pdf_bytes, fetch_status = fetch_pdf_bytes(link, args.timeout) + record["fetch_status"] = fetch_status + if not pdf_bytes: + record["extract_status"] = "fetch_failed" + records.append(record) + continue + + try: + extracted_text = extract_pdf_text(pdf_bytes) + except Exception as exc: # pragma: no cover - defensive branch for malformed PDFs + record["extract_status"] = f"pdf_parse_failed:{type(exc).__name__}" + records.append(record) + continue + + raw_sections = build_raw_sections(title, str(item.get("info_type") or ""), extracted_text) + record["raw_sections"] = raw_sections + record["summaries"] = dict(empty_sections) + record["sections"] = raw_sections + record["extract_status"] = choose_extract_status(raw_sections, title, str(item.get("info_type") or "")) + records.append(record) + + output_path.parent.mkdir(parents=True, exist_ok=True) + payload = { + "stock": args.stock, + "report_date": args.report_date, + "event_date": latest_info_date.isoformat() if latest_info_date else "", + "record_count": len(records), + "records": records, + } + output_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + print(f"✅ 公告提取结果已写入:{output_path}") + print(f"相关公告样本:{len(records)} 条") + + +if __name__ == "__main__": + main() diff --git a/.cursor/skills/earnings-preview/scripts/generate_report.py b/.cursor/skills/earnings-preview/scripts/generate_report.py new file mode 100644 index 000000000..cbcdb09fc --- /dev/null +++ b/.cursor/skills/earnings-preview/scripts/generate_report.py @@ -0,0 +1,1526 @@ +#!/usr/bin/env python3 +"""Template-driven earnings preview report generator.""" + +from __future__ import annotations + +import argparse +import json +import math +import re +import shutil +import subprocess +from collections import Counter +from datetime import date, datetime, timedelta +from pathlib import Path +from statistics import median, pstdev +from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple + + +TOKEN_RE = re.compile(r"\[\[([A-Z0-9_]+)\]\]") +REQUIRED_HEADINGS = [ + "# 财报预览报告", + "## 执行摘要", + "## 历史经营与财务趋势", + "## 预览季度预测框架", + "## 卖方预期与市场定价", + "## 公告与管理层线索", + "## 情景分析与市场反应", + "## 财报前交易定位", + "## 关键风险与验证点", + "## 附录:口径说明", +] +WEB_SOURCE_CONFIDENCE_CEILING = { + "official": 5, + "government": 4, + "association": 4, + "authoritative_media": 4, + "general_news": 3, + "inference": 1, +} +WEB_ALLOWED_FINDING_TYPES = { + "earnings_release_date", + "conference_call", + "industry_context", + "company_news", +} + +TICKER_FIELDS = ("order_book_id", "ticker", "stock_code", "symbol", "code") +NAME_FIELDS = ("display_name", "name", "stock_name", "company_name", "symbol_name", "symbol") + + +class ExternalFinding(dict): + pass + + +def parse_args() -> argparse.Namespace: + skill_dir = Path(__file__).resolve().parent.parent + parser = argparse.ArgumentParser(description="生成模板驱动的财报预览报告") + parser.add_argument("--stock", required=True, help="股票代码") + parser.add_argument("--company", help="公司名称,可选") + parser.add_argument("--quarter", required=True, help="目标预览季度,例如 2026q1") + 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="输出 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 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 quarter_to_string(value: Tuple[int, int]) -> str: + return f"{value[0]}q{value[1]}" + + +def previous_quarter(quarter: str) -> Optional[str]: + key = parse_quarter_key(quarter) + if not key: + return None + year, q = key + if q == 1: + return f"{year - 1}q4" + return f"{year}q{q - 1}" + + +def same_quarter_last_year(quarter: str) -> Optional[str]: + key = parse_quarter_key(quarter) + if not key: + return None + return f"{key[0] - 1}q{key[1]}" + + +def quarter_distance(from_quarter: str, to_quarter: str) -> Optional[int]: + from_key = parse_quarter_key(from_quarter) + to_key = parse_quarter_key(to_quarter) + if not from_key or not to_key: + return None + return (to_key[0] - from_key[0]) * 4 + (to_key[1] - from_key[1]) + + +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 extract_named_records(payload: Any, key: str) -> List[Any]: + if payload is None: + return [] + if isinstance(payload, dict) and isinstance(payload.get(key), list): + return list(payload[key]) + return extract_records(payload) + + +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 float_or_none(value: Any) -> Optional[float]: + if value in (None, "", "null"): + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + +def clean_text(value: Any) -> str: + return re.sub(r"\s+", " ", str(value or "")).strip() + + +def validate_web_search_records(records: Sequence[Any]) -> None: + if not records: + return + required_fields = { + "query", + "source_name", + "source_type", + "title", + "url", + "published_at", + "retrieved_at", + "summary", + "why_relevant", + "confidence", + "finding_type", + } + issues: List[str] = [] + for idx, item in enumerate(records, start=1): + if not isinstance(item, dict): + issues.append(f"第 {idx} 条网络搜索结果记录不是对象") + continue + missing = [field for field in required_fields if item.get(field) in (None, "", "null")] + if missing: + issues.append(f"第 {idx} 条网络搜索结果记录缺少字段:{', '.join(missing)}") + source_type = str(item.get("source_type") or "").strip() + if source_type not in WEB_SOURCE_CONFIDENCE_CEILING: + issues.append(f"第 {idx} 条网络搜索结果记录来源类型非法:{source_type or '空'}") + confidence = float_or_none(item.get("confidence")) + ceiling = WEB_SOURCE_CONFIDENCE_CEILING.get(source_type) + if confidence is None: + issues.append(f"第 {idx} 条网络搜索结果记录缺少置信度") + elif ceiling is not None and confidence > ceiling: + issues.append(f"第 {idx} 条网络搜索结果记录置信度 {confidence:g} 超过来源上限 {ceiling}") + finding_type = str(item.get("finding_type") or "").strip() + if finding_type not in WEB_ALLOWED_FINDING_TYPES: + issues.append(f"第 {idx} 条网络搜索结果记录 finding_type 非法:{finding_type or '空'}") + if issues: + raise ValueError("网络搜索结果校验失败:" + ";".join(issues)) + + +def extract_web_search_findings(records: Sequence[Any], report_date: date) -> List[ExternalFinding]: + findings: List[ExternalFinding] = [] + for item in records: + if not isinstance(item, dict): + continue + published_at = parse_iso_date(item.get("published_at")) + if published_at is None or published_at > report_date: + continue + event_date = parse_iso_date(item.get("event_date")) + findings.append( + ExternalFinding( + source_name=str(item.get("source_name") or "网络搜索来源").strip(), + title=clean_text(item.get("title")), + published_at=published_at, + summary=clean_text(item.get("summary")).rstrip("。;;!!??"), + why_relevant=clean_text(item.get("why_relevant")).rstrip("。;;!!??"), + confidence=int(float_or_none(item.get("confidence")) or 0), + finding_type=str(item.get("finding_type") or "").strip(), + url=str(item.get("url") or "").strip(), + event_date=event_date.isoformat() if event_date else "", + expected_window=str(item.get("expected_window") or "").strip(), + ) + ) + findings.sort(key=lambda item: (item["published_at"], item["confidence"]), reverse=True) + return findings + + +def is_company_report_record(record: Dict[str, Any]) -> bool: + data_source = float_or_none(record.get("data_source")) + if data_source is None: + return True + return abs(data_source) < 1e-9 + + +def billion_yuan_text(value: Optional[float]) -> str: + if value is None: + return "无数据" + return f"{value / 1e8:.2f}亿元" + + +def percent_text(value: Optional[float], digits: int = 1, signed: bool = True) -> str: + if value is None: + return "无数据" + sign = "+" if signed else "" + return f"{value:{sign}.{digits}f}%" + + +def x_text(value: Optional[float], digits: int = 2) -> str: + if value is None: + return "无数据" + return f"{value:.{digits}f}x" + + +def yuan_text(value: Optional[float], digits: int = 2) -> str: + if value is None: + return "无数据" + return f"{value:.{digits}f}元" + + +def compact_text(value: Any, limit: int = 160) -> str: + text = re.sub(r"\s+", " ", str(value or "")).strip() + if not text: + return "" + if len(text) <= limit: + return text + return text[:limit].rstrip() + "..." + + +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 stdev_or_none(values: Iterable[Optional[float]]) -> Optional[float]: + valid = [value for value in values if value is not None] + if len(valid) < 2: + return None + return float(pstdev(valid)) + + +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 safe_margin(numerator: Optional[float], denominator: Optional[float]) -> Optional[float]: + if numerator is None or denominator in (None, 0): + return None + return (numerator / denominator) * 100.0 + + +def select_margin_metric(rows: Sequence[Dict[str, Any]]) -> Tuple[str, str]: + candidates = [ + ("gross_margin", "毛利率"), + ("operating_margin", "营业利润率"), + ("net_margin", "净利率"), + ] + for field, label in candidates: + if any(item.get(field) is not None for item in rows): + return field, label + return "net_margin", "净利率" + + +def dedupe_financial_records(records: List[Any], stock: str, report_date: date) -> List[Dict[str, Any]]: + deduped: Dict[str, Dict[str, Any]] = {} + for item in records: + if not isinstance(item, dict) or normalize_ticker(item) != stock: + continue + item_date = parse_iso_date(item.get("info_date")) + if item_date and item_date > report_date: + continue + quarter = str(item.get("quarter") or "").lower() + if not parse_quarter_key(quarter): + continue + current = deduped.get(quarter) + current_date = parse_iso_date(current.get("info_date")) if current else None + if current is None or (item_date and (current_date is None or item_date >= current_date)): + deduped[quarter] = item + return [deduped[key] for key in sorted(deduped.keys(), key=lambda q: parse_quarter_key(q) or (0, 0))] + + +def latest_factor_series(records: List[Any], stock: str, field_name: str) -> List[Tuple[date, float]]: + series: Dict[date, float] = {} + for item in records: + if not isinstance(item, dict) or normalize_ticker(item) != stock: + continue + event_date = parse_iso_date(item.get("date")) + value = float_or_none(item.get(field_name)) + if event_date is None or value is None: + continue + series[event_date] = value + return sorted(series.items(), key=lambda pair: pair[0]) + + +def build_financial_trend(records: List[Any], stock: str, report_date: date) -> List[Dict[str, Any]]: + ordered = dedupe_financial_records(records, stock, report_date) + by_quarter = {str(item.get("quarter") or "").lower(): item for item in ordered} + trend: List[Dict[str, Any]] = [] + for item in ordered: + quarter = str(item.get("quarter") or "").lower() + revenue = float_or_none(item.get("revenue")) + net_profit = float_or_none(item.get("net_profit")) + gross_profit = float_or_none(item.get("gross_profit")) + operating_profit = float_or_none(item.get("profit_from_operation")) + operating_expense = float_or_none(item.get("operating_expense")) + cash_flow = float_or_none(item.get("cash_from_operating_activities")) + yoy_record = by_quarter.get(same_quarter_last_year(quarter) or "") + + yoy_revenue = float_or_none(yoy_record.get("revenue")) if yoy_record else None + yoy_profit = float_or_none(yoy_record.get("net_profit")) if yoy_record else None + 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 + gross_margin = safe_margin(gross_profit, revenue) + operating_margin = safe_margin(operating_profit, revenue) + net_margin = safe_margin(net_profit, revenue) + expense_ratio = ((operating_expense / revenue) * 100.0) if operating_expense is not None and revenue not in (None, 0) else None + cash_conversion = (cash_flow / net_profit) if cash_flow is not None and net_profit not in (None, 0) else None + + trend.append( + { + "quarter": quarter, + "info_date": item.get("info_date"), + "revenue": revenue, + "net_profit": net_profit, + "gross_margin": gross_margin, + "operating_margin": operating_margin, + "net_margin": net_margin, + "expense_ratio": expense_ratio, + "cash_flow": cash_flow, + "cash_conversion": cash_conversion, + "revenue_yoy": revenue_yoy, + "profit_yoy": profit_yoy, + } + ) + return trend + + +def attach_roe(trend: List[Dict[str, Any]], roe_series: List[Tuple[date, float]]) -> None: + for item in trend: + info_date = parse_iso_date(item.get("info_date")) + matched = None + for event_date, value in roe_series: + if info_date and event_date <= info_date: + matched = value + item["roe"] = matched + + +def forecast_target_quarter(trend: List[Dict[str, Any]], target_quarter: str) -> Dict[str, Any]: + by_quarter = {item["quarter"]: item for item in trend} + latest = trend[-1] if trend else {} + anchor_quarter = same_quarter_last_year(target_quarter) + anchor = by_quarter.get(anchor_quarter or "") + previous = by_quarter.get(previous_quarter(target_quarter) or "") + recent = trend[-4:] + margin_field, margin_label = select_margin_metric(recent or trend) + + revenue_yoy_base = median_or_none(item.get("revenue_yoy") for item in recent) + profit_yoy_base = median_or_none(item.get("profit_yoy") for item in recent) + margin_base = median_or_none(item.get(margin_field) for item in recent) + cash_conversion_base = median_or_none(item.get("cash_conversion") for item in recent) + expense_ratio_base = median_or_none(item.get("expense_ratio") for item in recent) + + anchor_revenue = anchor.get("revenue") if anchor else latest.get("revenue") + anchor_profit = anchor.get("net_profit") if anchor else latest.get("net_profit") + predicted_revenue = ( + anchor_revenue * (1.0 + (revenue_yoy_base or 0.0) / 100.0) if anchor_revenue is not None else None + ) + predicted_profit = ( + anchor_profit * (1.0 + (profit_yoy_base or 0.0) / 100.0) if anchor_profit is not None else None + ) + predicted_cash = ( + predicted_profit * cash_conversion_base if predicted_profit is not None and cash_conversion_base is not None else None + ) + + return { + "target_quarter": target_quarter, + "latest_quarter": latest.get("quarter") or "无数据", + "anchor_quarter": anchor_quarter or "无数据", + "anchor_revenue": anchor_revenue, + "anchor_profit": anchor_profit, + "predicted_revenue": predicted_revenue, + "predicted_profit": predicted_profit, + "margin_field": margin_field, + "margin_label": margin_label, + "predicted_margin": margin_base, + "predicted_cash_conversion": cash_conversion_base, + "predicted_expense_ratio": expense_ratio_base, + "predicted_cash_flow": predicted_cash, + "revenue_yoy_base": revenue_yoy_base, + "profit_yoy_base": profit_yoy_base, + "latest": latest, + "previous": previous, + } + + +def build_price_snapshot(stock_records: List[Any], index_records: List[Any], stock: str) -> Dict[str, Any]: + def to_series(records: List[Any], asset: Optional[str] = None) -> List[Tuple[date, float, Optional[float]]]: + result = [] + for item in records: + if not isinstance(item, dict): + continue + if asset is not None and normalize_ticker(item) != asset: + continue + event_date = parse_iso_date(item.get("datetime") or item.get("date")) + close = float_or_none(item.get("close")) + turnover = float_or_none(item.get("total_turnover")) + if event_date is None or close is None: + continue + result.append((event_date, close, turnover)) + result.sort(key=lambda row: row[0]) + return result + + stock_series = to_series(stock_records, stock) + index_series = to_series(index_records) + stock_return = None + index_return = None + excess_return = None + realized_vol = None + current_price = None + avg_turnover_20 = None + avg_turnover_prev20 = None + + if len(stock_series) >= 2: + stock_return = (stock_series[-1][1] / stock_series[0][1] - 1.0) * 100.0 + current_price = stock_series[-1][1] + returns = [] + for idx in range(1, len(stock_series)): + prev = stock_series[idx - 1][1] + curr = stock_series[idx][1] + if prev: + returns.append((curr / prev - 1.0) * 100.0) + if len(returns) >= 10: + realized_vol = stdev_or_none(returns[-20:]) + turnovers = [row[2] for row in stock_series if row[2] is not None] + if len(turnovers) >= 20: + avg_turnover_20 = sum(turnovers[-20:]) / 20.0 + if len(turnovers) >= 40: + avg_turnover_prev20 = sum(turnovers[-40:-20]) / 20.0 + + if len(index_series) >= 2: + index_return = (index_series[-1][1] / index_series[0][1] - 1.0) * 100.0 + if stock_return is not None and index_return is not None: + excess_return = stock_return - index_return + + return { + "stock_return": stock_return, + "index_return": index_return, + "excess_return": excess_return, + "realized_vol": realized_vol, + "current_price": current_price, + "avg_turnover_20": avg_turnover_20, + "avg_turnover_prev20": avg_turnover_prev20, + } + + +def build_consensus_snapshot(records: List[Any], stock: str) -> Optional[Dict[str, Any]]: + best: Optional[Tuple[date, Dict[str, Any]]] = None + for item in records: + if not isinstance(item, dict) or normalize_ticker(item) != stock: + continue + event_date = parse_iso_date(item.get("date") or item.get("create_tm")) + if event_date is None: + continue + if best is None or event_date >= best[0]: + best = (event_date, item) + return best[1] if best else None + + +def build_company_aliases(company_records: List[Any], stock: str, company_name: str) -> List[str]: + aliases = [company_name, company_name.replace("股份有限公司", ""), company_name.replace("股份", ""), stock, stock.split(".")[0]] + for item in company_records: + if not isinstance(item, dict) or normalize_ticker(item) != stock: + continue + for field in ("display_name", "name", "stock_name", "company_name", "abbrev_symbol", "symbol"): + value = str(item.get(field) or "").strip() + if value: + aliases.extend([value, value.replace("股份有限公司", ""), value.replace("股份", ""), value.replace(" ", "")]) + deduped = [] + seen = set() + for alias in aliases: + alias = alias.strip() + if alias and alias not in seen: + deduped.append(alias) + seen.add(alias) + return deduped + + +def pick_web_findings(findings: Sequence[ExternalFinding], finding_type: str) -> List[ExternalFinding]: + return [item for item in findings if item.get("finding_type") == finding_type] + + +def build_earnings_event_context(findings: Sequence[ExternalFinding]) -> str: + release_items = pick_web_findings(findings, "earnings_release_date") + call_items = pick_web_findings(findings, "conference_call") + if release_items: + item = release_items[0] + event_text = item.get("event_date") or item.get("expected_window") or "未验证" + suffix = f";电话会:{call_items[0].get('event_date') or call_items[0].get('expected_window') or '未验证'}" if call_items else "" + return f"{event_text}(数据来源:{item.get('source_name')},置信度{item.get('confidence')}){suffix}" + if call_items: + item = call_items[0] + event_text = item.get("event_date") or item.get("expected_window") or "未验证" + return f"电话会:{event_text}(数据来源:{item.get('source_name')},置信度{item.get('confidence')})" + return "未验证" + + +def build_network_search_block(findings: Sequence[ExternalFinding], heading: str, limit: int = 4) -> List[str]: + if not findings: + return [] + label_map = { + "earnings_release_date": "预计披露日", + "conference_call": "电话会", + "industry_context": "行业动态", + "company_news": "公司新闻", + } + lines = [heading] + for item in findings[:limit]: + event_hint = item.get("event_date") or item.get("expected_window") or item["published_at"].isoformat() + lines.append( + f"- **{event_hint} / {label_map.get(item['finding_type'], '网络搜索结果')} / {item['source_name']}**:" + f"{item['title']}。{item['summary']}。对本次预览的意义:{item['why_relevant']}。" + f" *数据来源:{item['source_name']},置信度{item['confidence']}*" + ) + return lines + + +def filter_related_reports(records: List[Any], stock: str, aliases: Sequence[str], report_date: date) -> List[Dict[str, Any]]: + related = [] + lower_aliases = [alias.lower() for alias in aliases] + strong_aliases = [alias for alias in lower_aliases if len(alias) >= 4 or re.search(r"\d", alias)] + stock_code = stock.split(".")[0].lower() + excluded_title_tokens = ( + "行业周报", + "周报", + "月报", + "专题", + "策略", + "宏观", + "晨报", + "行业点评合集", + "行业动态", + "动态点评", + "持仓分析", + "行业配置", + "板块", + "金股", + "组合", + ) + for item in records: + if not isinstance(item, dict) or normalize_ticker(item) != stock: + continue + if not is_company_report_record(item): + continue + create_date = parse_iso_date(item.get("create_tm") or item.get("create_time") or item.get("date")) + if create_date is None or create_date > report_date: + continue + title = str(item.get("report_title") or "") + summary = str(item.get("summary") or "") + title_lower = title.lower() + summary_lower = summary.lower() + report_main_id = str(item.get("report_main_id") or "").lower() + title_hit = any(alias in title_lower for alias in strong_aliases) + summary_hit = any(alias in summary_lower for alias in strong_aliases) + report_main_id_hit = bool(stock_code) and stock_code in report_main_id + company_style_title = any(token in title_lower for token in ("点评", "results", "earnings", "季报", "年报", "三季报", "中报", "q1", "q2", "q3", "q4", "更新", "预览", "前瞻")) + generic_title = any(token.lower() in title_lower for token in excluded_title_tokens) + if generic_title and not title_hit: + continue + if title_hit or (summary_hit and company_style_title) or (report_main_id_hit and (title_hit or summary_hit or company_style_title)): + related.append(item) + related.sort(key=lambda item: str(item.get("create_tm") or item.get("date") or ""), reverse=True) + return related + + +def build_research_snapshot(records: List[Any], stock: str, aliases: Sequence[str], report_date: date) -> Dict[str, Any]: + filtered = filter_related_reports(records, stock, aliases, report_date) + filtered.sort(key=lambda item: str(item.get("create_tm") or item.get("date") or ""), reverse=True) + display_reports = [item for item in filtered if extract_research_display_summary(item)] + + target_prices = [float_or_none(item.get("targ_price")) for item in filtered] + target_prices = [value for value in target_prices if value is not None] + profit_estimates = [float_or_none(item.get("net_profit_t")) for item in filtered] + profit_estimates = [value for value in profit_estimates if value is not None] + + return { + "count": len(filtered), + "reports": filtered[:8], + "display_reports": display_reports[:8], + "target_price_median": median_or_none(target_prices), + "target_price_min": min(target_prices) if target_prices else None, + "target_price_max": max(target_prices) if target_prices else None, + "profit_t_median": median_or_none(profit_estimates), + "profit_t_std": stdev_or_none(profit_estimates), + "institutes": sorted({str(item.get("institute") or "").strip() for item in filtered if item.get("institute")}), + } + + +def announcement_score(title: str, info_type: str, report_date: date, info_date: Optional[date]) -> int: + score = 0 + if "定期报告" in info_type: + score += 100 + if re.search(r"(年报|年度报告|半年报|半年度报告|一季报|第一季度报告|三季报|第三季度报告)", title): + score += 80 + if re.search(r"(主要经营数据|业绩说明会|业绩发布会|投资者关系活动记录表|调研活动信息)", title): + score += 50 + if re.search(r"(摘要|英文版|英文摘要)", title): + score -= 40 + if re.search(r"(董事会|监事会|法律意见书|独立财务顾问|章程|投票制实施细则)", title): + score -= 20 + if re.search(r"(激励计划|股票期权|限制性股票|行权|归属|注销|作废)", title): + score -= 40 + if info_date is not None: + distance = (report_date - info_date).days + if distance < 0: + score -= 100 + else: + score += max(0, 120 - distance) // 6 + return score + + +def target_quarter_report_tokens(target_quarter: str) -> List[str]: + key = parse_quarter_key(target_quarter) + if not key: + return [] + year, quarter = key + quarter_map = { + 1: [f"{year}年第一季度报告", f"{year}年一季报", f"{year}q1", f"{year}Q1"], + 2: [f"{year}年半年度报告", f"{year}年半年报", f"{year}年中报", f"{year}q2", f"{year}Q2"], + 3: [f"{year}年第三季度报告", f"{year}年三季报", f"{year}q3", f"{year}Q3"], + 4: [f"{year}年年度报告", f"{year}年年报", f"{year}q4", f"{year}Q4"], + } + return quarter_map.get(quarter, []) + + +def is_target_quarter_periodic_report(title: str, target_quarter: str) -> bool: + text = clean_text(title) + if not text: + return False + if not re.search(r"(报告|季报|年报|中报|业绩快报|业绩预告)", text): + return False + return any(token in text for token in target_quarter_report_tokens(target_quarter)) + + +def select_relevant_announcements( + records: List[Any], stock: str, report_date: date, target_quarter: str +) -> List[Dict[str, Any]]: + candidates = [] + lower_bound = report_date - timedelta(days=240) + for item in records: + if not isinstance(item, dict) or normalize_ticker(item) != stock: + continue + info_date = parse_iso_date(item.get("info_date") or item.get("date") or item.get("create_tm")) + if info_date is None or info_date < lower_bound or info_date > report_date: + continue + title = str(item.get("title") or "") + info_type = str(item.get("info_type") or "") + if is_target_quarter_periodic_report(title, target_quarter): + continue + score = announcement_score(title, info_type, report_date, info_date) + if score <= 0: + continue + row = dict(item) + row["_score"] = score + candidates.append(row) + candidates.sort(key=lambda item: (item["_score"], str(item.get("info_date") or "")), reverse=True) + deduped = [] + seen = set() + for item in candidates: + key = (str(item.get("info_date") or ""), str(item.get("title") or "")) + if key in seen: + continue + seen.add(key) + deduped.append(item) + if len(deduped) >= 6: + break + return deduped + + +def extract_section_display_text(extract_row: Dict[str, Any], key: str, limit: int = 220) -> str: + summaries = extract_row.get("summaries") if isinstance(extract_row.get("summaries"), dict) else {} + legacy_sections = extract_row.get("sections") if isinstance(extract_row.get("sections"), dict) else {} + raw_sections = extract_row.get("raw_sections") if isinstance(extract_row.get("raw_sections"), dict) else {} + if summaries: + value = summaries.get(key) + if value not in (None, ""): + return compact_text(value, limit) + return "" + if not raw_sections and legacy_sections: + value = legacy_sections.get(key) + if value not in (None, ""): + return compact_text(value, limit) + return "" + + +def build_peer_snapshot(financial_records: List[Any], peer_meta_records: List[Any]) -> Dict[str, Any]: + meta_map = { + normalize_ticker(item): normalize_name(item) or normalize_ticker(item) + for item in peer_meta_records + if isinstance(item, dict) and normalize_ticker(item) + } + rows = [] + seen: Dict[str, Dict[str, Any]] = {} + for item in financial_records: + if not isinstance(item, dict): + continue + stock = normalize_ticker(item) + quarter = str(item.get("quarter") or "").lower() + if not stock or not parse_quarter_key(quarter): + continue + key = (stock, quarter) + current = seen.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)): + seen[key] = item + latest_by_stock: Dict[str, Dict[str, Any]] = {} + for item in seen.values(): + stock = normalize_ticker(item) + current = latest_by_stock.get(stock) + current_key = parse_quarter_key(str(current.get("quarter") or "").lower()) if current else None + item_key = parse_quarter_key(str(item.get("quarter") or "").lower()) + if current is None or (item_key and (current_key is None or item_key >= current_key)): + latest_by_stock[stock] = item + + for stock, item in latest_by_stock.items(): + revenue = float_or_none(item.get("revenue")) + gross_profit = float_or_none(item.get("gross_profit")) + operating_profit = float_or_none(item.get("profit_from_operation")) + net_profit = float_or_none(item.get("net_profit")) + gross_margin = safe_margin(gross_profit, revenue) + operating_margin = safe_margin(operating_profit, revenue) + net_margin = safe_margin(net_profit, revenue) + rows.append( + { + "name": meta_map.get(stock, stock), + "order_book_id": stock, + "quarter": str(item.get("quarter") or ""), + "revenue": revenue, + "net_profit": net_profit, + "gross_margin": gross_margin, + "operating_margin": operating_margin, + "net_margin": net_margin, + } + ) + margin_field, margin_label = select_margin_metric(rows) + return { + "count": len(rows), + "rows": sorted(rows, key=lambda item: item["name"]), + "revenue_median": median_or_none(item.get("revenue") for item in rows), + "net_profit_median": median_or_none(item.get("net_profit") for item in rows), + "margin_field": margin_field, + "margin_label": margin_label, + "margin_median": median_or_none(item.get(margin_field) for item in rows), + } + + +def build_scenarios(forecast: Dict[str, Any], price_snapshot: Dict[str, Any]) -> List[Dict[str, Any]]: + base_band = max(2.0, (price_snapshot.get("realized_vol") or 2.0) * 1.4) + scenarios = [ + {"name": "乐观", "rev_delta": 5.0, "profit_delta": 8.0, "margin_delta": 1.0, "reaction": f"+{base_band:.1f}% ~ +{base_band * 2.0:.1f}%", "probability": "25%"}, + {"name": "中性", "rev_delta": 0.0, "profit_delta": 0.0, "margin_delta": 0.0, "reaction": f"-{base_band / 2.0:.1f}% ~ +{base_band / 2.0:.1f}%", "probability": "50%"}, + {"name": "悲观", "rev_delta": -5.0, "profit_delta": -8.0, "margin_delta": -1.0, "reaction": f"-{base_band * 2.0:.1f}% ~ -{base_band:.1f}%", "probability": "25%"}, + ] + rows = [] + for scenario in scenarios: + revenue = ( + forecast["anchor_revenue"] * (1.0 + ((forecast.get("revenue_yoy_base") or 0.0) + scenario["rev_delta"]) / 100.0) + if forecast.get("anchor_revenue") is not None + else None + ) + profit = ( + forecast["anchor_profit"] * (1.0 + ((forecast.get("profit_yoy_base") or 0.0) + scenario["profit_delta"]) / 100.0) + if forecast.get("anchor_profit") is not None + else None + ) + margin = ( + (forecast.get("predicted_margin") or 0.0) + scenario["margin_delta"] + if forecast.get("predicted_margin") is not None + else None + ) + rows.append( + { + "name": scenario["name"], + "revenue": revenue, + "profit": profit, + "margin": margin, + "reaction": scenario["reaction"], + "probability": scenario["probability"], + } + ) + return rows + + +def clean_summary(text: str) -> str: + return re.sub(r"\s+", " ", str(text or "")).strip() + + +def extract_research_display_summary(item: Dict[str, Any]) -> str: + summaries = item.get("summaries") if isinstance(item.get("summaries"), dict) else {} + for key in ("core_view", "report_summary", "investment_takeaway"): + value = summaries.get(key) + if value not in (None, ""): + return clean_summary(value) + for key in ("report_summary", "llm_summary"): + value = item.get(key) + if value not in (None, ""): + return clean_summary(value) + return "" + + +def markdown_link(label: str, url: Optional[str]) -> str: + if not url: + return label + return f"[{label}]({url})" + + +def align_price_to_target(current_price: Optional[float], target_price: Optional[float]) -> Optional[float]: + if current_price is None or target_price in (None, 0): + return current_price + candidates = [current_price, current_price / 10.0, current_price / 100.0] + scored = [] + for candidate in candidates: + if candidate <= 0: + continue + scored.append((abs(math.log(candidate / target_price)), candidate)) + if not scored: + return current_price + return min(scored, key=lambda item: item[0])[1] + + +def build_exec_summary( + company: str, + target_quarter: str, + industry_name: str, + trend: List[Dict[str, Any]], + forecast: Dict[str, Any], + price_snapshot: Dict[str, Any], + research_snapshot: Dict[str, Any], + consensus_snapshot: Optional[Dict[str, Any]], + web_findings: Sequence[ExternalFinding], +) -> str: + latest = trend[-1] if trend else {} + margin_label = forecast.get("margin_label", "利润率") + lines = [ + f"- {company} 所属行业为 `{industry_name}`,当前预览目标季度为 `{target_quarter}`;最新已披露季度为 `{latest.get('quarter', '无数据')}`。", + f"- 基于最近 4 个已披露季度的同比中位数,基准情形下我们预计营收 {billion_yuan_text(forecast.get('predicted_revenue'))}、净利润 {billion_yuan_text(forecast.get('predicted_profit'))}、{margin_label} {percent_text(forecast.get('predicted_margin'), signed=False)}。", + f"- 财报前 3 个月股价涨跌幅为 {percent_text(price_snapshot.get('stock_return'))},相对沪深300超额收益为 {percent_text(price_snapshot.get('excess_return'))};20 日日收益波动率约为 {percent_text(price_snapshot.get('realized_vol'), signed=False)}。", + ] + if research_snapshot["count"]: + lines.append( + f"- 近 120 天共检索到 {research_snapshot['count']} 篇相关研报,卖方目标价中位数为 {yuan_text(research_snapshot.get('target_price_median'))}," + f"年度净利润口径中位数为 {billion_yuan_text(research_snapshot.get('profit_t_median'))}。" + ) + if consensus_snapshot: + lines.append( + f"- 最新一致预期快照日期为 {consensus_snapshot.get('date') or '无数据'},目标价为 {yuan_text(float_or_none(consensus_snapshot.get('con_targ_price')))}。" + ) + release_items = pick_web_findings(web_findings, "earnings_release_date") + if release_items: + item = release_items[0] + lines.append( + f"- 网络搜索结果显示目标季度预计披露时间为 {item.get('event_date') or item.get('expected_window') or '未验证'}," + f"来源 {item.get('source_name')};可用于安排财报前窗口与仓位节奏。" + ) + lines.append("") + lines.append("*数据来源:RQData,置信度5*") + if web_findings: + lines.append( + f"*补充网络搜索结果:{web_findings[0].get('source_name')} 等 {len(web_findings)} 个来源," + f"置信度{min(item.get('confidence', 0) for item in web_findings)}-{max(item.get('confidence', 0) for item in web_findings)}*" + ) + return "\n".join(lines) + + +def build_announcement_section( + selected_announcements: List[Dict[str, Any]], + announcement_extract_records: List[Any], + web_findings: Sequence[ExternalFinding], +) -> str: + intro_lines: List[str] = [] + release_items = pick_web_findings(web_findings, "earnings_release_date") + call_items = pick_web_findings(web_findings, "conference_call") + context_items = pick_web_findings(web_findings, "industry_context") + pick_web_findings(web_findings, "company_news") + if release_items or call_items or context_items: + intro_lines.extend(build_network_search_block(release_items + call_items + context_items, "### 网络搜索结果")) + intro_lines.append("") + + if not selected_announcements: + return "\n".join( + intro_lines + [ + "- 报告期前未识别到与本次财报预览直接相关的重点公告。", + "", + "*数据来源:RQData(公告链接 / 可选原文提取),置信度5*", + ] + ) + + extract_index: Dict[str, Dict[str, Any]] = {} + for row in announcement_extract_records: + if not isinstance(row, dict): + continue + link_key = str(row.get("announcement_link") or "") + title_key = str(row.get("title") or "") + if link_key: + extract_index[link_key] = row + if title_key: + extract_index[title_key] = row + + announcement_rows = [] + detail_lines = [] + company_intro_lines = [] + management_lines = [] + outlook_lines = [] + risk_lines = [] + note_lines = [] + has_annual_or_interim_extract_source = False + + for item in selected_announcements: + link = str(item.get("announcement_link") or "") + extract_row = extract_index.get(link) or extract_index.get(str(item.get("title") or "")) + extract_status = "链接可查" + if extract_row: + if bool(extract_row.get("is_annual_or_interim_report")): + has_annual_or_interim_extract_source = True + company_intro = extract_section_display_text(extract_row, "company_intro", 220) + management_discussion = extract_section_display_text(extract_row, "management_discussion", 220) + risk_warning = extract_section_display_text(extract_row, "risk_warning", 220) + outlook = extract_section_display_text(extract_row, "outlook", 220) + if any((company_intro, management_discussion, risk_warning, outlook)): + extract_status = "已提炼关键片段" + elif str(extract_row.get("fetch_status") or "") not in ("", "ok", "skipped"): + extract_status = "原文获取不完整" + elif str(extract_row.get("extract_status") or "") not in ("", "ok", "未提取", "skipped_non_annual_interim"): + extract_status = "未形成可引用片段" + if company_intro: + company_intro_lines.append(f"- `{item.get('title')}`:{company_intro}") + if management_discussion: + management_lines.append(f"- `{item.get('title')}`:{management_discussion}") + if risk_warning: + risk_lines.append(f"- `{item.get('title')}`:{risk_warning}") + if outlook: + outlook_lines.append(f"- `{item.get('title')}`:{outlook}") + if extract_status != "已提炼关键片段" and extract_status != "链接可查": + note_lines.append(f"- `{item.get('title')}`:{extract_status},报告保留原文链接供查阅。") + + announcement_rows.append( + [ + str(item.get("info_date") or "无数据"), + compact_text(item.get("title") or "无标题", 42), + str(item.get("info_type") or "无数据"), + str(item.get("media") or "无数据"), + extract_status, + markdown_link("原文", link), + ] + ) + detail_lines.append( + f"- `{item.get('info_date') or '无数据'}` {item.get('title') or '无标题'};类型:{item.get('info_type') or '无数据'};来源:{item.get('media') or '无数据'};{markdown_link('原文', link)}" + ) + + if not company_intro_lines: + company_intro_lines = ["- 当前未形成可直接引用的公司背景片段。"] if has_annual_or_interim_extract_source else ["- 本次相关公告以季度更新或临时公告为主,未单列公司背景片段。"] + if not management_lines: + management_lines = ["- 当前未形成可直接引用的管理层表述片段。"] if has_annual_or_interim_extract_source else ["- 本次相关公告以季度更新或临时公告为主,未单列管理层表述片段。"] + if not outlook_lines: + outlook_lines = ["- 当前未形成可直接引用的经营展望片段。"] if has_annual_or_interim_extract_source else ["- 本次相关公告以季度更新或临时公告为主,未单列经营展望片段。"] + if not risk_lines: + risk_lines = ["- 当前未获取可直接引用的风险提示片段。"] + if not note_lines: + note_lines = ["- 本节优先引用正式财报和近期经营沟通中的关键表述,其他公告保留原文链接备查。"] + + return "\n".join( + intro_lines + [ + format_table(["日期", "标题", "类型", "来源", "原文情况", "链接"], announcement_rows), + "", + "### 公告清单", + *detail_lines, + "", + "### 公司背景片段", + *company_intro_lines, + "", + "### 管理层近期表述", + *management_lines, + "", + "### 经营展望与验证点", + *outlook_lines, + "", + "### 风险与关注点", + *risk_lines, + "", + "### 补充说明", + *note_lines, + "", + "*数据来源:RQData(公告链接 / 可选原文提取),置信度5*", + ] + ) + + +def build_historical_trend_section(trend: List[Dict[str, Any]]) -> str: + latest = trend[-1] if trend else {} + recent = trend[-4:] + margin_field, margin_label = select_margin_metric(recent or trend) + recent_revenue_yoy = median_or_none(item.get("revenue_yoy") for item in recent) + recent_profit_yoy = median_or_none(item.get("profit_yoy") for item in recent) + recent_margins = [item.get(margin_field) for item in recent if item.get(margin_field) is not None] + margin_min = min(recent_margins) if recent_margins else None + margin_max = max(recent_margins) if recent_margins else None + lines = [ + f"- 最新已披露季度 `{latest.get('quarter', '无数据')}` 的营收为 {billion_yuan_text(latest.get('revenue'))},净利润为 {billion_yuan_text(latest.get('net_profit'))},营收同比 {percent_text(latest.get('revenue_yoy'))},净利润同比 {percent_text(latest.get('profit_yoy'))}。", + f"- 最近 4 个季度{margin_label}中位数约为 {percent_text(median_or_none(item.get(margin_field) for item in recent), signed=False)}," + f"现金转化率中位数约为 {x_text(median_or_none(item.get('cash_conversion') for item in recent))}。", + f"- 最近 4 个季度 ROE 中位数约为 {percent_text(median_or_none(item.get('roe') for item in recent), signed=False)},费用率中位数约为 {percent_text(median_or_none(item.get('expense_ratio') for item in recent), signed=False)}。", + "", + ] + headers = ["季度", "营收", "营收同比", "净利润", "净利润同比", margin_label, "费用率", "现金转化率", "ROE"] + rows = [ + [ + item["quarter"], + billion_yuan_text(item.get("revenue")), + percent_text(item.get("revenue_yoy")), + billion_yuan_text(item.get("net_profit")), + percent_text(item.get("profit_yoy")), + percent_text(item.get(margin_field), signed=False), + percent_text(item.get("expense_ratio"), signed=False), + x_text(item.get("cash_conversion")), + percent_text(item.get("roe"), signed=False), + ] + for item in trend[-8:] + ] + lines.append(format_table(headers, rows)) + lines.extend( + [ + "", + "### 历史观察要点", + f"- 最近 4 个季度营收同比中位数约为 {percent_text(recent_revenue_yoy)},净利润同比中位数约为 {percent_text(recent_profit_yoy)}。", + ( + f"- 最近 4 个季度{margin_label}区间约为 {percent_text(margin_min, signed=False)} 至 {percent_text(margin_max, signed=False)}," + "说明利润率波动相对可控。" + if margin_min is not None and margin_max is not None + else f"- {margin_label}样本不足,暂时无法稳定刻画利润率波动区间。" + ), + "- 现金转化率持续高于 1x,意味着经营现金流对利润仍有较强覆盖,财报前应重点验证这一点是否延续。", + ] + ) + lines.append("") + lines.append("*数据来源:RQData,置信度5*") + return "\n".join(lines) + + +def build_forecast_section(forecast: Dict[str, Any], target_quarter: str) -> str: + gap = quarter_distance(forecast.get("latest_quarter") or "", target_quarter) + margin_label = forecast.get("margin_label", "利润率") + lines = [ + f"- 预测锚点优先使用去年同期 `{forecast.get('anchor_quarter', '无数据')}` 与最近 4 个季度同比中位数;如果去年同期缺失,则退化为最近一期数据。", + f"- 基准收入同比假设为 {percent_text(forecast.get('revenue_yoy_base'))},基准利润同比假设为 {percent_text(forecast.get('profit_yoy_base'))};" + f"对应 {target_quarter} 的营收预测为 {billion_yuan_text(forecast.get('predicted_revenue'))},净利润预测为 {billion_yuan_text(forecast.get('predicted_profit'))}。", + f"- 基准{margin_label}假设为 {percent_text(forecast.get('predicted_margin'), signed=False)},基准现金转化率假设为 {x_text(forecast.get('predicted_cash_conversion'))}。" + if forecast.get("predicted_margin") is not None + else "- 当前缺少足够的利润率/现金流历史,无法完整构建利润率框架。", + ] + if gap is not None: + lines.append(f"- 最新已披露季度与目标季度之间相隔 {gap} 个季度;间隔越长,预测不确定性越高。") + + headers = ["指标", "基准预测", "主要依据"] + rows = [ + ["营收", billion_yuan_text(forecast.get("predicted_revenue")), f"去年同期 `{forecast.get('anchor_quarter', '无数据')}` 与最近 4 季度营收同比中位数"], + ["净利润", billion_yuan_text(forecast.get("predicted_profit")), f"去年同期 `{forecast.get('anchor_quarter', '无数据')}` 与最近 4 季度净利润同比中位数"], + [margin_label, percent_text(forecast.get("predicted_margin"), signed=False), f"最近 4 季度{margin_label}中位数"], + ["现金转化率", x_text(forecast.get("predicted_cash_conversion")), "最近 4 季度经营现金流/净利润中位数"], + ] + lines.extend( + [ + "", + format_table(headers, rows), + "", + "### 本次财报最值得验证的三个点", + f"- **收入节奏**:实际营收是否接近 {billion_yuan_text(forecast.get('predicted_revenue'))},以及同比增速是否明显偏离 {percent_text(forecast.get('revenue_yoy_base'))} 这一中枢。", + f"- **利润兑现**:实际净利润是否接近 {billion_yuan_text(forecast.get('predicted_profit'))},利润弹性是否高于收入弹性。", + f"- **利润率与现金流**:{margin_label}是否守住 {percent_text(forecast.get('predicted_margin'), signed=False)} 一线,现金转化率是否仍接近 {x_text(forecast.get('predicted_cash_conversion'))}。", + "", + "*数据来源:RQData(历史数据),预测为分析师估算,置信度4*", + ] + ) + return "\n".join(lines) + + +def build_expectation_section( + consensus_snapshot: Optional[Dict[str, Any]], + research_snapshot: Dict[str, Any], + peer_snapshot: Dict[str, Any], +) -> str: + lines = [] + if consensus_snapshot: + lines.extend( + [ + f"- 最新一致预期快照日期为 {consensus_snapshot.get('date') or '无数据'},目标价为 {yuan_text(float_or_none(consensus_snapshot.get('con_targ_price')))}。", + f"- RQData consensus forward buckets 显示:营收 t1/t2/t3 分别为 " + f"{billion_yuan_text(float_or_none(consensus_snapshot.get('comp_con_operating_revenue_t1')))} / " + f"{billion_yuan_text(float_or_none(consensus_snapshot.get('comp_con_operating_revenue_t2')))} / " + f"{billion_yuan_text(float_or_none(consensus_snapshot.get('comp_con_operating_revenue_t3')))};" + f"净利润 t1/t2/t3 分别为 " + f"{billion_yuan_text(float_or_none(consensus_snapshot.get('comp_con_net_profit_t1')))} / " + f"{billion_yuan_text(float_or_none(consensus_snapshot.get('comp_con_net_profit_t2')))} / " + f"{billion_yuan_text(float_or_none(consensus_snapshot.get('comp_con_net_profit_t3')))}。" + ] + ) + else: + lines.append("- 当前未识别到有效一致预期快照。") + + if research_snapshot["count"]: + if research_snapshot.get("target_price_median") is not None: + target_text = ( + f"目标价中位数为 {yuan_text(research_snapshot.get('target_price_median'))},区间为 " + f"{yuan_text(research_snapshot.get('target_price_min'))} 至 {yuan_text(research_snapshot.get('target_price_max'))}。" + ) + else: + target_text = "样本中未给出有效目标价,更多体现为年度利润和推荐口径。" + lines.append( + f"- 近 120 天卖方研报共 {research_snapshot['count']} 篇,覆盖机构包括 {('、'.join(research_snapshot['institutes'][:6]) or '无数据')};" + f"{target_text}" + ) + lines.append( + f"- 研报年度净利润口径中位数为 {billion_yuan_text(research_snapshot.get('profit_t_median'))}," + f"离散度约为 {billion_yuan_text(research_snapshot.get('profit_t_std'))}。" + ) + lines.append("- 这些研报口径是年度维度校准,不应直接等同于目标季度单季预期,但可以帮助判断全年乐观假设是否已经被市场提前计入。") + lines.append("") + if research_snapshot.get("display_reports"): + lines.append("### 近期研报样本") + for item in research_snapshot["display_reports"][:5]: + lines.append( + f"- **{item.get('institute') or '未知机构'} / {item.get('create_tm') or item.get('date') or '无日期'}**:" + f"`{item.get('report_title') or '未命名研报'}`;" + f"目标价 {yuan_text(float_or_none(item.get('targ_price')))}," + f"净利润口径 {billion_yuan_text(float_or_none(item.get('net_profit_t')))};" + f"观点提要:{extract_research_display_summary(item)}" + ) + else: + lines.append("- 近期无可用卖方研报样本。") + + if peer_snapshot["count"]: + peer_margin_label = peer_snapshot.get("margin_label", "利润率") + peer_margin_field = peer_snapshot.get("margin_field", "net_margin") + lines.extend( + [ + "", + "### 可比公司校准", + f"- 已提供 {peer_snapshot['count']} 家可比公司最近一期数据;营收中位数为 {billion_yuan_text(peer_snapshot.get('revenue_median'))}," + f"净利润中位数为 {billion_yuan_text(peer_snapshot.get('net_profit_median'))},{peer_margin_label}中位数为 {percent_text(peer_snapshot.get('margin_median'), signed=False)}。", + "", + format_table( + ["可比公司", "最新季度", "营收", "净利润", peer_margin_label], + [ + [ + f"{item['name']}
`{item['order_book_id']}`", + item["quarter"], + billion_yuan_text(item.get("revenue")), + billion_yuan_text(item.get("net_profit")), + percent_text(item.get(peer_margin_field), signed=False), + ] + for item in peer_snapshot["rows"][:5] + ], + ), + ] + ) + else: + lines.extend(["", "### 可比公司校准", "- 未提供可比公司数据,本轮仅基于卖方预期与公司自身历史序列做校准。"]) + + lines.extend(["", "*数据来源:RQData,置信度5*"]) + return "\n".join(lines) + + +def build_scenario_section(scenarios: List[Dict[str, Any]], margin_label: str) -> str: + headers = ["情景", "营收", "净利润", margin_label, "预期市场反应", "概率"] + rows = [ + [ + item["name"], + billion_yuan_text(item.get("revenue")), + billion_yuan_text(item.get("profit")), + percent_text(item.get("margin"), signed=False), + item["reaction"], + item["probability"], + ] + for item in scenarios + ] + lines = [ + "- 乐观/中性/悲观情景以基准同比假设为中心上下浮动,并用最近 20 个交易日的 realized volatility 估算财报日反应区间。", + "", + format_table(headers, rows), + "", + "*数据来源:RQData(历史数据/价格数据),情景分析为分析师估算,置信度4*", + ] + return "\n".join(lines) + + +def build_trading_setup_section( + price_snapshot: Dict[str, Any], + research_snapshot: Dict[str, Any], + consensus_snapshot: Optional[Dict[str, Any]], + margin_label: str, +) -> str: + implied_target = research_snapshot.get("target_price_median") + if implied_target is None and consensus_snapshot is not None: + implied_target = float_or_none(consensus_snapshot.get("con_targ_price")) + current_price = align_price_to_target(price_snapshot.get("current_price"), implied_target) + target_upside = ((implied_target / current_price - 1.0) * 100.0) if implied_target is not None and current_price not in (None, 0) else None + turnover_change = ( + ((price_snapshot["avg_turnover_20"] / price_snapshot["avg_turnover_prev20"] - 1.0) * 100.0) + if price_snapshot.get("avg_turnover_20") is not None and price_snapshot.get("avg_turnover_prev20") not in (None, 0) + else None + ) + + if price_snapshot.get("excess_return") is not None and price_snapshot["excess_return"] > 10 and (target_upside is None or target_upside < 15): + stance = f"财报前预期已不低,若没有更强的利润与{margin_label}惊喜,股价容错率偏低。" + elif price_snapshot.get("excess_return") is not None and price_snapshot["excess_return"] < -5 and (target_upside is None or target_upside > 10): + stance = "财报前市场预期相对保守,若利润/现金流不差于基准情景,存在修复空间。" + else: + stance = "当前更接近中性 setup,重点看财报是否打破市场已有预期区间。" + + lines = [ + f"- 近 3 个月股价表现为 {percent_text(price_snapshot.get('stock_return'))},沪深300 同期为 {percent_text(price_snapshot.get('index_return'))},超额收益为 {percent_text(price_snapshot.get('excess_return'))}。", + f"- 当前价格约为 {yuan_text(current_price, 2)};" + + ( + f"基于卖方/一致预期目标价的隐含空间约为 {percent_text(target_upside)}。" + if target_upside is not None + else "当前缺少有效目标价,无法计算隐含空间。" + ), + f"- 近 20 日成交额相对前 20 日变化约为 {percent_text(turnover_change)},20 日日收益波动率约为 {percent_text(price_snapshot.get('realized_vol'), signed=False)}。", + f"- **定位判断**:{stance}", + "- 财报前如果再出现明显超额收益扩张,而卖方目标价和一致预期没有同步抬升,通常意味着市场已经开始抢跑。", + "- 如果实际财报只能满足基准情景而无法带来新的全年上修线索,短期更容易出现“兑现式”波动。", + "", + "*数据来源:RQData,置信度5*", + ] + return "\n".join(lines) + + +def build_risk_section( + target_quarter: str, + trend: List[Dict[str, Any]], + forecast: Dict[str, Any], + price_snapshot: Dict[str, Any], + research_snapshot: Dict[str, Any], + web_findings: Sequence[ExternalFinding], +) -> str: + latest_quarter = trend[-1]["quarter"] if trend else "无数据" + gap = quarter_distance(latest_quarter, target_quarter) + margin_field = forecast.get("margin_field", "net_margin") + margin_label = forecast.get("margin_label", "利润率") + margin_vol = stdev_or_none(item.get(margin_field) for item in trend[-4:]) + lines = [] + if gap is not None and gap >= 2: + lines.append(f"- **信息滞后风险**:最新财报仅到 `{latest_quarter}`,距离目标季度 `{target_quarter}` 已有 {gap} 个季度,预测误差会明显放大。") + if margin_vol is not None and margin_vol > 2.0: + lines.append(f"- **{margin_label}波动风险**:最近 4 个季度{margin_label}标准差约为 {percent_text(margin_vol, signed=False)},利润弹性可能高于收入弹性。") + if price_snapshot.get("realized_vol") is not None and price_snapshot["realized_vol"] > 2.5: + lines.append(f"- **交易波动风险**:近 20 日日收益波动率约为 {percent_text(price_snapshot['realized_vol'], signed=False)},财报日放大波动的概率较高。") + if research_snapshot.get("profit_t_std") is not None and research_snapshot["profit_t_std"] > 0: + lines.append(f"- **卖方分歧风险**:年度净利润口径离散度约为 {billion_yuan_text(research_snapshot['profit_t_std'])},说明市场预期并不集中。") + release_items = pick_web_findings(web_findings, "earnings_release_date") + if not release_items: + lines.append(f"- **时间安排未验证风险**:当前未通过网络搜索结果确认 `{target_quarter}` 的预计披露日,财报前节奏判断需保守处理。") + elif release_items[0].get("confidence", 0) <= 3: + lines.append("- **预计披露日置信度风险**:当前预计披露日来自较低置信度网络搜索结果,需等待公司或交易所进一步确认。") + if not lines: + lines.append("- 当前未识别到突出的新增风险,但仍需重点验证利润率、现金流和市场预期差。") + lines.append("") + lines.append("*数据来源:RQData,置信度5*") + return "\n".join(lines) + + +def build_appendix( + args: argparse.Namespace, + company: str, + industry_name: str, + counts: Dict[str, int], + earnings_event_context: str, + web_findings: Sequence[ExternalFinding], +) -> str: + lines = [ + f"- 报告日期:{args.report_date};公司:{company}({args.stock});目标季度:{args.quarter}。", + f"- 行业:{industry_name};预计披露信息:{earnings_event_context}。", + f"- 历史趋势采用财务 PIT 数据去重口径,预测与情景分析为分析师估算。", + "- 卖方预期部分优先采用一致预期、财报点评和公司高相关研报,尽量避免低相关样本干扰。", + "- 公告章节优先引用正式财报和近期经营沟通中的管理层表述,其他公告保留原文链接供查阅。", + ( + f"- 本次共纳入 {len(web_findings)} 条网络搜索结果,用于补充预计披露日、电话会或行业动态。" + if web_findings + else "- 本次未纳入网络搜索结果,正文不对预计披露日和电话会做确定性表述。" + ), + f"- 本次样本载入:财务 {counts['historical_financials']} 条、股价 {counts['price_recent']} 条、研报 {counts['research_reports']} 条、公告 {counts['announcement_raw']} 条、网络搜索结果 {counts['web_search_findings']} 条。", + "", + "*数据来源:RQData,置信度5*", + ] + return "\n".join(lines) + + +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"警告:仓库内 report-renderer 渲染失败:{exc}") + + print("警告:未找到可用的 HTML 渲染器,保留 Markdown 输出") + return None + + +def main() -> None: + args = parse_args() + data_dir = Path(args.data_dir).expanduser() + report_date = parse_iso_date(args.report_date) or date.today() + + company_records = extract_records(read_json_file(data_dir / "company_info.json")) + industry_records = extract_records(read_json_file(data_dir / "industry.json")) + historical_financials = extract_records(read_json_file(data_dir / "historical_financials.json")) + roe_records = extract_records(read_json_file(data_dir / "roe_history.json")) + price_recent = extract_records(read_json_file(data_dir / "price_recent.json")) + hs300_recent = extract_records(read_json_file(data_dir / "hs300_recent.json")) + consensus_records = extract_records(read_json_file(data_dir / "consensus.json")) + research_records = extract_records(read_json_file(data_dir / "research_reports.json")) + announcement_records = extract_records(read_json_file(data_dir / "announcement_raw.json")) + announcement_extract_records = extract_named_records(read_json_file(data_dir / "announcement_extracts.json"), "records") + peers_financials = extract_records(read_json_file(data_dir / "peers_financials.json")) + peers_instruments = extract_records(read_json_file(data_dir / "peers_instruments.json")) + web_search_records = extract_records(read_json_file(data_dir / "web_search_findings.json")) + validate_web_search_records(web_search_records) + web_findings = extract_web_search_findings(web_search_records, report_date) + + company_name = args.company + if not company_name: + for item in company_records: + if isinstance(item, dict) and normalize_ticker(item) == args.stock: + company_name = normalize_name(item) + if company_name: + break + company_name = company_name or args.stock + + industry_name = "无数据" + for item in industry_records: + if isinstance(item, dict) and normalize_ticker(item) == args.stock: + industry_name = str(item.get("first_industry_name") or "无数据") + break + + trend = build_financial_trend(historical_financials, args.stock, report_date) + if not trend: + raise ValueError("historical_financials.json 中缺少可用的历史财务数据。") + + attach_roe(trend, latest_factor_series(roe_records, args.stock, "return_on_equity_weighted_average")) + forecast = forecast_target_quarter(trend, args.quarter.lower()) + price_snapshot = build_price_snapshot(price_recent, hs300_recent, args.stock) + consensus_snapshot = build_consensus_snapshot(consensus_records, args.stock) + aliases = build_company_aliases(company_records, args.stock, company_name) + research_snapshot = build_research_snapshot(research_records, args.stock, aliases, report_date) + selected_announcements = select_relevant_announcements(announcement_records, args.stock, report_date, args.quarter.lower()) + peer_snapshot = build_peer_snapshot(peers_financials, peers_instruments) + scenarios = build_scenarios(forecast, price_snapshot) + earnings_event_context = build_earnings_event_context(web_findings) + + exec_summary = build_exec_summary(company_name, args.quarter.lower(), industry_name, trend, forecast, price_snapshot, research_snapshot, consensus_snapshot, web_findings) + historical_trend = build_historical_trend_section(trend) + forecast_framework = build_forecast_section(forecast, args.quarter.lower()) + expectation_section = build_expectation_section(consensus_snapshot, research_snapshot, peer_snapshot) + announcement_section = build_announcement_section(selected_announcements, announcement_extract_records, web_findings) + scenario_section = build_scenario_section(scenarios, forecast.get("margin_label", "利润率")) + trading_setup = build_trading_setup_section(price_snapshot, research_snapshot, consensus_snapshot, forecast.get("margin_label", "利润率")) + risk_section = build_risk_section(args.quarter.lower(), trend, forecast, price_snapshot, research_snapshot, web_findings) + appendix = build_appendix( + args, + company_name, + industry_name, + { + "company_info": len(company_records), + "industry": len(industry_records), + "historical_financials": len(historical_financials), + "roe_history": len(roe_records), + "price_recent": len(price_recent), + "hs300_recent": len(hs300_recent), + "consensus": len(consensus_records), + "research_reports": len(research_records), + "announcement_raw": len(announcement_records), + "peers_financials": len(peers_financials), + "peers_instruments": len(peers_instruments), + "web_search_findings": len(web_search_records), + }, + earnings_event_context, + web_findings, + ) + + template_text = Path(args.template).read_text(encoding="utf-8") + report_text = render_template( + template_text, + { + "REPORT_DATE": args.report_date, + "COMPANY_NAME": company_name, + "STOCK_CODE": args.stock, + "TARGET_QUARTER": args.quarter.lower(), + "EARNINGS_EVENT_CONTEXT": earnings_event_context, + "EXEC_SUMMARY": exec_summary, + "HISTORICAL_TREND": historical_trend, + "FORECAST_FRAMEWORK": forecast_framework, + "EXPECTATION_SECTION": expectation_section, + "ANNOUNCEMENT_SECTION": announcement_section, + "SCENARIO_SECTION": scenario_section, + "TRADING_SETUP": trading_setup, + "RISK_SECTION": risk_section, + "APPENDIX": appendix, + }, + ) + + output_path = Path(args.output).expanduser() if args.output else data_dir / f"earnings_preview_{args.stock}_{args.quarter}_{args.report_date}.md" + 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() diff --git a/.cursor/skills/idea-generation/SKILL.md b/.cursor/skills/idea-generation/SKILL.md new file mode 100644 index 000000000..70b194aa7 --- /dev/null +++ b/.cursor/skills/idea-generation/SKILL.md @@ -0,0 +1,446 @@ +--- +name: rq-idea-generation +description: | + 创建模板驱动的投资创意生成报告,基于真实股票池与财务指标完成系统化量化筛选,并在必要时结合主题/政策/行业实时信息做第二阶段验证,输出价值、成长、质量三类候选及其跟踪重点。 + `RQData CLI` 负责股票池、公司元数据、财务与估值因子主数据;`web_search` 只在需要补充主题验证、政策催化或行业动态时使用,且必须先落为结构化 JSON,再由当前 LLM 基于结构化快照和 `assets/template.md` 回写客户可读正文,最后渲染 HTML。 + + 务必使用此技能当用户: + - 明确请求投资创意、idea generation、找投资机会、系统化选股 + - 需要对一个股票池做价值 / 成长 / 质量筛选 + - 想快速形成一份带量化依据的候选清单 + - 需要把筛选规则、候选结果和跟踪重点整理成正式报告 + + 不适用场景: + - 单个公司首次深度覆盖 -> initiating-coverage + - 财报发布后的单家公司分析 -> earnings-analysis + - 只要一句话推荐且不需要完整报告 +compatibility: "Requires rqdata CLI, Bash, python3. Report structure is managed by idea-generation/assets/template.md." +--- + +# 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](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` 指定,不能写死固定路径 + +## 目录结构 + +```text +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.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`(可选,仅供 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](references/data_contract.md)。 + +## 工作流 + +### 步骤 1:准备参数 + +```bash +REPORT_DATE="${REPORT_DATE:-$(date +%F)}" +TOP_N="${TOP_N:-10}" +STRATEGY="${STRATEGY:-all}" +START_QUARTER="$(python3 - < "$DATA_DIR/stock_pool.json" +``` + +### 步骤 3:分批采集基础信息、财务和估值指标 + +```bash +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` 参数,不是 `fields` +- `financials_history.json` 与 `latest_financials.json` 可以复用同一份查询结果,脚本会自动按股票抽取最新季度并计算同比 +- 全市场数据量较大,必须按批次抓取,不能把全部 `order_book_id` 一次性塞给单个命令 + +### 步骤 4:生成结构化筛选快照 + +```bash +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](assets/template.md) 回写: + +- `summaries.exec_summary` +- `summaries.universe_overview` +- `summaries.metric_scoreboard` +- `summaries.value_section` +- `summaries.growth_section` +- `summaries.quality_section` +- `summaries.overlap_section` +- `summaries.risk_section` +- `summaries.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](assets/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` diff --git a/.cursor/skills/idea-generation/assets/template.md b/.cursor/skills/idea-generation/assets/template.md new file mode 100644 index 000000000..84140ded2 --- /dev/null +++ b/.cursor/skills/idea-generation/assets/template.md @@ -0,0 +1,71 @@ +# 投资创意生成报告 + +- 报告日期:[[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]] diff --git a/.cursor/skills/idea-generation/references/data_contract.md b/.cursor/skills/idea-generation/references/data_contract.md new file mode 100644 index 000000000..2647429f3 --- /dev/null +++ b/.cursor/skills/idea-generation/references/data_contract.md @@ -0,0 +1,202 @@ +# 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` + +允许格式: + +```json +[ + { "order_book_id": "600519.XSHG" } +] +``` + +或: + +```json +{ + "data": [ + { "order_book_id": "600519.XSHG" } + ] +} +``` + +最关键字段: + +- `order_book_id` + +用途: + +- 定义筛选股票池 + +## 2. `instrument_meta.json` + +典型字段: + +- `order_book_id` +- `symbol` +- `display_name` +- `sector_code_name` +- `industry_name` + +用途: + +- 补充公司名称和行业 / 板块信息 + +## 3. `latest_financials.json` + +典型字段: + +- `order_book_id` +- `quarter` +- `info_date` +- `revenue` +- `net_profit` +- `total_assets` +- `total_liabilities` + +用途: + +- 为每只股票抽取最近一期财务快照 +- 计算资产负债率 + +## 4. `financials_history.json` + +字段与 `latest_financials.json` 一致。 + +用途: + +- 查找同季度去年同期数据 +- 计算营收同比、净利润同比 + +## 5. `roe.json` + +典型字段: + +- `order_book_id` +- `date` +- `return_on_equity_weighted_average` + +用途: + +- 质量与价值筛选 + +## 6. `market_cap.json` + +典型字段: + +- `order_book_id` +- `date` +- `market_cap` + +用途: + +- 候选排序与规模描述 + +## 7. `pe_ratio.json` + +典型字段: + +- `order_book_id` +- `date` +- `pe_ratio` + +用途: + +- 价值筛选 + +## 8. `pb_ratio.json` + +典型字段: + +- `order_book_id` +- `date` +- `pb_ratio` + +用途: + +- 价值筛选 + +## 9. `web_search_findings.json` + +该文件可选,仅用于量化筛选后的主题 / 政策 / 催化剂验证。 + +每条记录至少包含: + +- `query` +- `source_name` +- `source_type` +- `title` +- `url` +- `published_at` +- `retrieved_at` +- `summary` +- `why_relevant` +- `confidence` +- `finding_type` + +推荐附加字段: + +- `subject` +- `related_tickers` +- `stance` + +说明: + +- `generate_report.py` 不直接读取该文件;当前 LLM 应在回写 `idea_screening_snapshot.json -> summaries.*` 前读取它 +- `web_search_findings.json` 不能替代财务、估值、股票池和排序主数据 +- `source_type` / `confidence` 需遵守 [references/web_search.md](web_search.md) 的来源等级约束 + +## 10. `idea_screening_snapshot.json` + +这是 `build_screening_snapshot.py` 生成、并由当前 LLM 继续回写 summary 的核心中间文件。 + +关键结构: + +- `report_date` +- `strategy` +- `inputs.*` +- `universe.stock_pool_size` +- `universe.valid_financials` +- `universe.valid_roe` +- `universe.valid_pe` +- `universe.valid_pb` +- `universe.latest_quarters[]` +- `universe.top_sectors[]` +- `universe.median_metrics.*` +- `strategies.value / growth / quality` +- `strategies.*.enabled` +- `strategies.*.candidate_count` +- `strategies.*.display_count` +- `strategies.*.thresholds.*` +- `strategies.*.display_candidates[]` +- `strategies.*.median_metrics.*` +- `overlap.candidate_count` +- `overlap.display_candidates[]` +- `risk_flags.*` +- `summaries.exec_summary` +- `summaries.universe_overview` +- `summaries.metric_scoreboard` +- `summaries.value_section` +- `summaries.growth_section` +- `summaries.quality_section` +- `summaries.overlap_section` +- `summaries.risk_section` +- `summaries.appendix` + +说明: + +- `summaries.*` 初始应为空字符串,表示 Python 没有越界生成正文 +- `summaries.*` 必须由当前 LLM 回写客户可读正文,不能只复制表格字段或写流程描述 +- 若某策略未启用,相应 summary 也必须解释该章节的对照价值或后续用途 +- `strategies.*.thresholds` 记录了本次实际使用的用户阈值,最终报告必须按这些阈值解释结果,不能自行改口径 + +## 解析约定 + +- 所有文件都允许 `{"data": [...]}`、`{"data": {...}}`、`[...]`、`{...}` 四种包装方式 +- `financial-indicator` 文件中的值字段不是统一 `value`,而是因子名本身 +- 同一股票同一季度若存在多条财务记录,脚本会按 `info_date` 选择最新披露版本 +- 缺失值不会抛异常,但报告中必须明确体现为“无数据 / 未启用 / 未验证” +- 金额类字段最终报告中应按客户可读口径展示,例如市值与净利润使用“亿元” +- 最终报告面向客户阅读:正文由当前 LLM 基于快照事实和可选 `web_search_findings.json` 生成,Python 只负责结构化数据与表格渲染 diff --git a/.cursor/skills/idea-generation/references/web_search.md b/.cursor/skills/idea-generation/references/web_search.md new file mode 100644 index 000000000..1b6f0fcbb --- /dev/null +++ b/.cursor/skills/idea-generation/references/web_search.md @@ -0,0 +1,101 @@ +# 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_search` to 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: + +- `query` +- `source_name` +- `source_type` +- `title` +- `url` +- `published_at` +- `retrieved_at` +- `summary` +- `why_relevant` +- `confidence` +- `finding_type` + +Recommended fields: + +- `subject` +- `related_tickers` +- `stance` + +## Allowed `finding_type` + +- `theme_validation` +- `industry_context` +- `policy_context` +- `company_news` +- `catalyst` + +## Source Types And Confidence Ceiling + +- `official`: max confidence `5` +- `government`: max confidence `4` +- `association`: max confidence `4` +- `authoritative_media`: max confidence `4` +- `general_news`: max confidence `3` +- `inference`: max confidence `1` + +## 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 + +```json +{ + "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" + } + ] +} +``` diff --git a/.cursor/skills/idea-generation/scripts/build_screening_snapshot.py b/.cursor/skills/idea-generation/scripts/build_screening_snapshot.py new file mode 100644 index 000000000..cead578b7 --- /dev/null +++ b/.cursor/skills/idea-generation/scripts/build_screening_snapshot.py @@ -0,0 +1,643 @@ +#!/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() diff --git a/.cursor/skills/idea-generation/scripts/generate_report.py b/.cursor/skills/idea-generation/scripts/generate_report.py new file mode 100644 index 000000000..811625ff8 --- /dev/null +++ b/.cursor/skills/idea-generation/scripts/generate_report.py @@ -0,0 +1,389 @@ +#!/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', '-'))}
`{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', '-'))}
`{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', '-'))}
`{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', '-'))}
`{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() diff --git a/.cursor/skills/initiating-coverage/SKILL.md b/.cursor/skills/initiating-coverage/SKILL.md new file mode 100644 index 000000000..dec071f09 --- /dev/null +++ b/.cursor/skills/initiating-coverage/SKILL.md @@ -0,0 +1,708 @@ +--- +name: rq-initiating-coverage +description: | + 创建模板驱动的首次覆盖研究报告,基于真实财务、股权结构、交易、分红、市场预期、卖方摘要与可比公司数据输出长篇结构化报告。 + `RQData CLI` 负责财务、交易、分红、预期、可比公司与公司基础资料主数据;若需要补充管理层履历、行业规模、竞争格局或政策背景,可额外使用 `web_search` 获取实时定性信息,并先落为结构化 JSON,再由当前报告脚本将其以客户可读方式纳入对应章节。 + + 务必使用此技能当用户: + - 明确请求首次覆盖、initiating coverage、深度公司研究、完整公司分析框架 + - 需要为新纳入跟踪的公司建立完整的公司研究报告、对比框架和估值定位 + - 需要公司概况、财务轨迹、股权结构、卖方预期、可比估值等一揽子分析输入 + + 不适用场景: + - 财报后单次复盘 -> earnings-analysis + - 财报前瞻 -> earnings-preview + - 行业整体研究 -> sector-overview +compatibility: "Requires rqdata CLI, Bash, python3. Report structure is managed by initiating-coverage/assets/template.md." +--- + +# RQ 股票研究 - 首次覆盖 + +## 核心原则 + +- 所有内容必须遵循三阶段流程:数据采集 -> 报告生成 -> HTML 渲染 +- `assets/template.md` 是唯一报告模板来源;Python 只做数据归一化、指标计算、占位符填充和结构校验 +- Python 可以基于真实数据生成客户可读摘要,但不能硬写脱离数据的主观结论、固定公司故事或静态投资判断 +- 当前实现不是 `skills-reference` 中 5 任务 DOCX 工程的逐任务复刻,而是将其压缩为单次自动化长篇报告;但研究深度、来源规范、图表替代表达和证据覆盖不得明显降级 +- 最新财报季度必须从真实 `historical_financials.json` 中按 `info_date <= report-date` 自动识别 +- `financial-indicator` 必须使用 `factor + start_date/end_date` +- peers 选择必须显式落盘为 `peer_pool.json`,不能在脚本里偷藏固定可比公司列表 +- `research_reports.json` 若提供 `data_source` 字段,应将 `0` 视为公司研报主样本;其他来源默认不进入最终正文 +- `research_reports.json` 若要进入最终报告,必须先在同一文件内回写 `summaries.core_view` 等客户可读摘要;最终报告不直接截断原始 `summary` +- 缺失数据时必须明确写“无数据 / 未提供 / 未验证”,不能留空 + +## 数据源分工 + +### `RQData CLI` 负责 + +- 公司基础资料、行业口径、股本与十大股东 +- 历史财务、ROE、估值、价格、换手率、分红 +- 一致预期、目标价、卖方研报结构化字段 +- 可比公司池、可比公司财务与估值 + +### `web_search` 负责 + +- 管理层公开履历补充 +- 行业规模、竞争格局、政策环境与监管变化 +- 公司重大新闻、产能 / 产品 / 组织调整等实时定性背景 +- RQData 无法直接提供的竞争对手定性描述 + +### `web_search` 禁止替代的内容 + +- 财务数据、估值指标、价格、换手率、分红与一致预期 +- 可比公司筛选、peer 排名和量化定位 +- 任何本应由 RQData 提供的结构化主数据 + +## `web_search` 使用规则 + +详细字段、来源等级、落盘示例和 fallback 规则见 [references/web_search.md](references/web_search.md)。 + +允许补充的内容: + +- 管理层履历、重要任职背景、治理事件 +- 行业规模、竞争格局、政策动态和监管要求 +- 公司近期产品、产能、组织或合作进展 + +落盘要求: + +- 所有 `web_search` 结果必须先写入 `web_search_findings.json` +- 只写结构化记录,不把搜索草稿或碎片化笔记直接塞进报告 +- 若未提供该文件,报告仍可交付,但相关定性背景必须保持“未验证”边界 +- 即便提供了 `web_search_findings.json`,它也只能补充定性语境,不能改写量化结论 + +## 硬性规则 + +以下任一条违反,视为输出失败: + +- `[MUST-1]` 财务数据、估值数据、价格数据、分红数据和一致预期必须来自 `RQData CLI` +- `[MUST-2]` `web_search` 只补充定性信息,不能替代结构化金融主数据 +- `[MUST-3]` 金额类字段在客户稿中必须换算为“亿元”等可读口径 +- `[MUST-4]` peers 必须来自显式落盘并可复核的可比公司池,不能在代码里写死 +- `[MUST-5]` 若使用卖方研报,最终报告必须优先消费 `summaries.core_view` 等客户可读摘要,不直接截断原始 `summary` +- `[MUST-6]` 每个关键数据点或关键结论都要标 `数据来源:XXX,置信度X` +- `[MUST-7]` 客户稿不得暴露 `LLM`、`skill`、文件名、JSON 字段名、workflow 术语或内部状态 +- `[MUST-8]` 低置信度网络搜索结果不得单独支撑核心投资结论 +- `[MUST-9]` 图表若未生成,必须由等价表格、趋势表或对比表完成降级,不得让关键分析断层 + +## 确信度评级 + +- `5`:RQData CLI、交易所公告、上市公司官网、官方监管披露 +- `4`:政府 / 监管 / 行业协会 / 官方机构、权威财经媒体 +- `3`:一般媒体或二手整理,但来源清晰且与其他来源一致 +- `2`:单一来源、细节不完整、时点未充分验证 +- `1`:推断、估算窗口、未验证信息 + +使用规则: + +- 混合结论的置信度取关键来源中的最低值 +- 推断类文字不得标成高置信度 +- 低置信度信息只能作为补充背景,不得单独推导出评级或估值判断 + +## 图表 / 图片需求 + +当前实现以表格和趋势事实完成最小可交付版本,但首次覆盖报告仍必须定义图表需求;若图表缺失,必须用等价结构化表达降级。 + +- 图表名称:收入与净利润五年轨迹 +- 图表目的:展示 5 年历史财务变化和最新同口径趋势 +- 使用的数据文件:`historical_financials.json` +- 关键字段:`quarter`、`revenue`、`net_profit` +- 建议图表类型:柱线组合图 +- 回答问题:公司收入和利润的扩张节奏是否稳定 +- 放置位置:`## 历史财务轨迹` +- 若图表缺失:保留同口径财务表和近 8 季趋势表 + +- 图表名称:盈利质量与现金流结构图 +- 图表目的:展示毛利率、ROE、资产负债率和现金转化率变化 +- 使用的数据文件:`historical_financials.json`、`roe_history.json` +- 关键字段:`gross_profit`、`revenue`、`return_on_equity_weighted_average`、`cash_from_operating_activities`、`total_assets`、`total_liabilities` +- 建议图表类型:折线图或分组柱图 +- 回答问题:盈利质量是改善还是弱化,现金流是否跟得上利润 +- 放置位置:`## 盈利质量与现金流` +- 若图表缺失:保留质量指标表和现金流对比表 + +- 图表名称:可比公司估值定位图 +- 图表目的:比较目标公司与 peers 的市值、ROE、PE、PB、股息率定位 +- 使用的数据文件:`peer_pool.json`、`peer_*` +- 关键字段:`market_cap`、`return_on_equity_weighted_average`、`pe_ratio`、`pb_ratio`、`dividend_yield` +- 建议图表类型:散点图、条形图或对比表 +- 回答问题:公司当前估值在可比样本中偏高还是偏低 +- 放置位置:`## 可比公司与估值定位` +- 若图表缺失:保留 peer 对比表与中位数偏离表 + +- 图表名称:股价与股东回报图 +- 图表目的:展示目标公司相对基准的股价表现与历史分红 +- 使用的数据文件:`price_history.json`、`benchmark_price.json`、`dividend_history.json` +- 关键字段:`close`、`dividend_cash_before_tax`、`declaration_announcement_date` +- 建议图表类型:收益曲线图 + 分红时间轴 +- 回答问题:市场历史定价与股东回报特征如何 +- 放置位置:`## 交易表现与股东回报` +- 若图表缺失:保留收益表、换手表和分红表 + +## 目标产出 + +- 报告长度:10-16 页 +- 推荐中文字符数:3500-6000 +- 输出文件: + - Markdown 报告 + - HTML 报告(若本地已安装渲染器) +- 输出目录必须由 `--data-dir` / `--output` 指定,不能写死固定路径 + +## 目录结构 + +```text +initiating-coverage/ +├── SKILL.md +├── scripts/ +│ └── generate_report.py +├── assets/ +│ └── template.md +└── references/ + ├── data_contract.md + └── web_search.md +``` + +## 输入文件契约 + +原始数据目录由 `--data-dir` 指定,脚本会按下列文件名查找输入: + +- `company_info.json` +- `industry.json` +- `shares.json` +- `shareholder_top10.json` +- `historical_financials.json` +- `roe_history.json` +- `market_cap.json` +- `pe_ratio.json` +- `pb_ratio.json` +- `dividend_yield.json` +- `price_history.json` +- `turnover_history.json` +- `benchmark_price.json` +- `dividend_history.json` +- `consensus.json` +- `research_reports.json` +- `peer_pool.json` +- `peer_company_info.json` +- `peer_industry.json` +- `peer_latest_financials.json` +- `peer_roe.json` +- `peer_market_cap.json` +- `peer_pe_ratio.json` +- `peer_pb_ratio.json` +- `peer_dividend_yield.json` +- `web_search_findings.json`(可选) + +完整字段说明见 [references/data_contract.md](references/data_contract.md)。 + +## 工作流 + +### 步骤 1:准备参数 + +```bash +REPORT_DATE="${REPORT_DATE:-$(date +%F)}" +ORDER_BOOK_ID="${ORDER_BOOK_ID:-600519.XSHG}" +BENCHMARK_ORDER_BOOK_ID="${BENCHMARK_ORDER_BOOK_ID:-000300.XSHG}" +PRICE_START_DATE="$(python3 - < "$DATA_DIR/company_info.json" + +rqdata stock cn industry --payload "{ + \"order_book_ids\": [\"$ORDER_BOOK_ID\"], + \"date\": \"$REPORT_DATE\", + \"level\": 0, + \"source\": \"citics_2019\" +}" --format json > "$DATA_DIR/industry.json" + +rqdata stock cn shares --payload "{ + \"order_book_ids\": [\"$ORDER_BOOK_ID\"], + \"start_date\": \"$CONSENSUS_START_DATE\", + \"end_date\": \"$REPORT_DATE\" +}" --format json > "$DATA_DIR/shares.json" + +rqdata stock cn shareholder-top10 --payload "{ + \"order_book_ids\": [\"$ORDER_BOOK_ID\"], + \"start_date\": \"$CONSENSUS_START_DATE\", + \"end_date\": \"$REPORT_DATE\", + \"start_rank\": 1, + \"end_rank\": 10 +}" --format json > "$DATA_DIR/shareholder_top10.json" + +rqdata stock cn financial --payload "{ + \"order_book_ids\": [\"$ORDER_BOOK_ID\"], + \"fields\": [ + \"revenue\", + \"net_profit\", + \"gross_profit\", + \"total_assets\", + \"total_liabilities\", + \"total_equity\", + \"cash_from_operating_activities\", + \"cash_flow_from_investing_activities\", + \"cash_flow_from_financing_activities\" + ], + \"start_quarter\": \"$HISTORY_START_QUARTER\", + \"end_quarter\": \"$HISTORY_END_QUARTER\", + \"statements\": \"all\" +}" --format json > "$DATA_DIR/historical_financials.json" +``` + +### 步骤 3:采集目标公司估值、交易、分红与卖方数据 + +```bash +rqdata stock cn financial-indicator --payload "{ + \"order_book_ids\": [\"$ORDER_BOOK_ID\"], + \"factor\": \"return_on_equity_weighted_average\", + \"start_date\": \"$PRICE_START_DATE\", + \"end_date\": \"$FACTOR_DATE\" +}" --format json > "$DATA_DIR/roe_history.json" + +for factor in market_cap pe_ratio pb_ratio dividend_yield; do + rqdata stock cn financial-indicator --payload "{ + \"order_book_ids\": [\"$ORDER_BOOK_ID\"], + \"factor\": \"$factor\", + \"start_date\": \"$FACTOR_DATE\", + \"end_date\": \"$FACTOR_DATE\" + }" --format json > "$DATA_DIR/${factor}.json" +done + +rqdata stock cn price --payload "{ + \"order_book_ids\": [\"$ORDER_BOOK_ID\"], + \"start_date\": \"$PRICE_START_DATE\", + \"end_date\": \"$REPORT_DATE\", + \"fields\": [\"close\", \"volume\", \"total_turnover\", \"high\", \"low\"], + \"adjust_type\": \"post\" +}" --format json > "$DATA_DIR/price_history.json" + +rqdata stock cn turnover-rate --payload "{ + \"order_book_ids\": [\"$ORDER_BOOK_ID\"], + \"start_date\": \"$PRICE_START_DATE\", + \"end_date\": \"$REPORT_DATE\" +}" --format json > "$DATA_DIR/turnover_history.json" + +rqdata index price --payload "{ + \"order_book_ids\": [\"$BENCHMARK_ORDER_BOOK_ID\"], + \"start_date\": \"$PRICE_START_DATE\", + \"end_date\": \"$REPORT_DATE\", + \"fields\": [\"close\"] +}" --format json > "$DATA_DIR/benchmark_price.json" + +rqdata stock cn dividend --payload "{ + \"order_book_ids\": [\"$ORDER_BOOK_ID\"], + \"start_date\": \"$(python3 - < "$DATA_DIR/dividend_history.json" + +rqdata stock cn consensus --payload "{ + \"order_book_ids\": [\"$ORDER_BOOK_ID\"], + \"start_date\": \"$CONSENSUS_START_DATE\", + \"end_date\": \"$REPORT_DATE\", + \"report_range\": 3 +}" --format json > "$DATA_DIR/consensus.json" +``` + +先根据财务数据识别最新季度与预测年份: + +```bash +read LATEST_QUARTER LATEST_YEAR NEXT_YEAR < report_date.isoformat(): + continue + if best is None or quarter > best: + best = quarter + +if not best: + raise SystemExit("未识别到最新财报季度") +year = int(best[:4]) +print(best, year, year + 1) +PY +) +EOF + +python3 - "$DATA_DIR" "$ORDER_BOOK_ID" "$LATEST_YEAR" "$NEXT_YEAR" "$CONSENSUS_START_DATE" "$REPORT_DATE" <<'PY' +import json +import subprocess +import sys +from pathlib import Path + +data_dir = Path(sys.argv[1]) +stock = sys.argv[2] +years = [sys.argv[3], sys.argv[4]] +start_date = sys.argv[5] +end_date = sys.argv[6] + +rows = [] +for year in years: + payload = json.dumps({ + "order_book_ids": [stock], + "fiscal_year": year, + "start_date": start_date, + "end_date": end_date, + "date_rule": "create_tm", + }, ensure_ascii=False) + output = subprocess.check_output( + ["rqdata", "stock", "cn", "research-reports", "--payload", payload, "--format", "json"], + text=True, + ) + data = json.loads(output) + rows.extend(data if isinstance(data, list) else data.get("data", [])) + +data_dir.joinpath("research_reports.json").write_text( + json.dumps(rows, ensure_ascii=False, indent=2), + encoding="utf-8", +) +PY +``` + +若 `research_reports.json` 需要进入最终正文,应先在原记录内补齐客户可读摘要: + +- 摘要输入位置:`research_reports.json -> records[].summary` +- 摘要回写位置:`research_reports.json -> records[].summaries.core_view` +- 仅 `data_source=0` 且公司直接相关的记录可以进入最终报告 + +### 步骤 3.5:可选的管理层 / 行业 / 竞争语境补充 + +当用户需要更完整的首次覆盖定性背景时,可执行该步骤。 + +- 使用 `web_search` 补充管理层、行业规模、政策环境、竞争格局或公司最新重大动态 +- 结果必须写入 `web_search_findings.json` +- 这类结果只补充定性语境,不参与 Python 量化计算或 peer 排序 + +### 步骤 4:生成可比公司池并采集 peers 数据 + +先生成全市场股票列表和行业映射: + +```bash +rqdata stock cn list --payload "{ + \"date\": \"$FACTOR_DATE\", + \"type\": \"CS\" +}" --format json > "$DATA_DIR/stock_list.json" + +python3 - "$DATA_DIR" "$ORDER_BOOK_ID" "$REPORT_DATE" "$FACTOR_DATE" <<'PY' +import json +import subprocess +import sys +from pathlib import Path + +data_dir = Path(sys.argv[1]) +target = sys.argv[2] +report_date = sys.argv[3] +factor_date = sys.argv[4] + +target_industry = json.loads(data_dir.joinpath("industry.json").read_text(encoding="utf-8")) +target_rows = target_industry if isinstance(target_industry, list) else target_industry.get("data", []) +target_row = next(item for item in target_rows if isinstance(item, dict) and item.get("order_book_id") == target) +target_third = target_row.get("third_industry_name") +target_second = target_row.get("second_industry_name") + +stock_list = json.loads(data_dir.joinpath("stock_list.json").read_text(encoding="utf-8")) +stock_rows = stock_list if isinstance(stock_list, list) else stock_list.get("data", []) +ids = [row["order_book_id"] for row in stock_rows if isinstance(row, dict) and row.get("order_book_id")] + +industry_rows = [] +for start in range(0, len(ids), 800): + chunk = ids[start:start + 800] + payload = json.dumps({ + "order_book_ids": chunk, + "date": report_date, + "level": 0, + "source": "citics_2019", + }, ensure_ascii=False) + output = subprocess.check_output( + ["rqdata", "stock", "cn", "industry", "--payload", payload, "--format", "json"], + text=True, + ) + batch = json.loads(output) + industry_rows.extend(batch if isinstance(batch, list) else batch.get("data", [])) + +data_dir.joinpath("industry_universe.json").write_text( + json.dumps(industry_rows, ensure_ascii=False, indent=2), + encoding="utf-8", +) + +third_matches = [row for row in industry_rows if isinstance(row, dict) and row.get("third_industry_name") == target_third] +second_matches = [row for row in industry_rows if isinstance(row, dict) and row.get("second_industry_name") == target_second] +selected = third_matches if len(third_matches) >= 6 else second_matches + +candidate_ids = [] +seen = set() +for row in selected: + stock = row.get("order_book_id") + if not stock or stock in seen: + continue + seen.add(stock) + candidate_ids.append(stock) + +payload = json.dumps({ + "order_book_ids": candidate_ids, + "factor": "market_cap", + "start_date": factor_date, + "end_date": factor_date, +}, ensure_ascii=False) +output = subprocess.check_output( + ["rqdata", "stock", "cn", "financial-indicator", "--payload", payload, "--format", "json"], + text=True, +) +factor_rows = json.loads(output) + +market_cap = {} +for row in factor_rows: + if isinstance(row, dict) and row.get("order_book_id") and row.get("market_cap") not in (None, "", "null"): + market_cap[row["order_book_id"]] = float(row["market_cap"]) + +peer_rows = [] +for stock in candidate_ids: + if stock not in market_cap: + continue + peer_rows.append({ + "order_book_id": stock, + "selection_level": "third" if stock in {item.get("order_book_id") for item in third_matches} else "second", + "market_cap": market_cap[stock], + }) + +peer_rows.sort(key=lambda item: item["market_cap"], reverse=True) +peer_rows = peer_rows[:6] + +if target not in {item["order_book_id"] for item in peer_rows} and target in market_cap: + peer_rows = [{"order_book_id": target, "selection_level": "target", "market_cap": market_cap[target]}] + peer_rows[:5] + +data_dir.joinpath("peer_pool.json").write_text( + json.dumps(peer_rows, ensure_ascii=False, indent=2), + encoding="utf-8", +) +PY +``` + +再采集 peers 元数据与最新快照: + +```bash +PEER_IDS="$(python3 - "$DATA_DIR/peer_pool.json" <<'PY' +import json +import sys +rows = json.load(open(sys.argv[1], "r", encoding="utf-8")) +print(json.dumps([row["order_book_id"] for row in rows], ensure_ascii=False)) +PY +)" + +rqdata stock cn instruments --payload "{ + \"order_book_ids\": $PEER_IDS +}" --format json > "$DATA_DIR/peer_company_info.json" + +rqdata stock cn industry --payload "{ + \"order_book_ids\": $PEER_IDS, + \"date\": \"$REPORT_DATE\", + \"level\": 0, + \"source\": \"citics_2019\" +}" --format json > "$DATA_DIR/peer_industry.json" + +rqdata stock cn financial --payload "{ + \"order_book_ids\": $PEER_IDS, + \"fields\": [ + \"revenue\", + \"net_profit\", + \"gross_profit\", + \"total_assets\", + \"total_liabilities\", + \"cash_from_operating_activities\" + ], + \"start_quarter\": \"$HISTORY_START_QUARTER\", + \"end_quarter\": \"$HISTORY_END_QUARTER\", + \"statements\": \"all\" +}" --format json > "$DATA_DIR/peer_latest_financials.json" + +rqdata stock cn financial-indicator --payload "{ + \"order_book_ids\": $PEER_IDS, + \"factor\": \"return_on_equity_weighted_average\", + \"start_date\": \"$PRICE_START_DATE\", + \"end_date\": \"$FACTOR_DATE\" +}" --format json > "$DATA_DIR/peer_roe.json" + +for factor in market_cap pe_ratio pb_ratio dividend_yield; do + rqdata stock cn financial-indicator --payload "{ + \"order_book_ids\": $PEER_IDS, + \"factor\": \"$factor\", + \"start_date\": \"$FACTOR_DATE\", + \"end_date\": \"$FACTOR_DATE\" + }" --format json > "$DATA_DIR/peer_${factor}.json" +done +``` + +### 步骤 5:生成 Markdown / HTML + +```bash +python3 initiating-coverage/scripts/generate_report.py \ + --stock "$ORDER_BOOK_ID" \ + --data-dir "$DATA_DIR" \ + --report-date "$REPORT_DATE" \ + --output "$OUTPUT_MD" +``` + +## 阶段门控 + +### Gate 1:公司与财务主数据齐备 + +- 已识别最新财报季度 +- 价格历史、一致预期、peer 主数据齐全 +- 公司、股东、财务、估值、分红、交易文件存在 + +### Gate 2:卖方与可比样本整理完成 + +- `research_reports.json` 已抓取且公司直接相关样本可识别 +- 若需要进入最终报告,`summaries.core_view` 已补齐 +- `peer_pool.json` 已生成,且可比公司数量足以支撑对比 + +### Gate 3:可选网络搜索结果完成 + +- 若启用定性补充,`web_search_findings.json` 已落盘 +- 字段完整、来源等级可解释 +- 网络搜索结果仅用于管理层 / 行业 / 政策 / 竞争背景,不替代量化主数据 + +### Gate 4:成稿完成 + +- Markdown 已生成 +- 若本地渲染器存在,HTML 已生成 +- 客户稿不暴露内部术语 +- 长度、章节、来源标注和结构化对比均达标 + +## 模板规则 + +- 报告必须严格基于 [template.md](assets/template.md) 生成 +- 占位符采用 `[[TOKEN]]` 语法,不使用 Jinja +- 当前模板仅允许以下占位符: + - `[[REPORT_DATE]]` + - `[[COMPANY_NAME]]` + - `[[STOCK_CODE]]` + - `[[EXEC_SUMMARY]]` + - `[[COMPANY_PROFILE]]` + - `[[OWNERSHIP_SECTION]]` + - `[[FINANCIAL_TRAJECTORY]]` + - `[[QUALITY_AND_CASHFLOW]]` + - `[[EXPECTATION_AND_SELLSIDE]]` + - `[[PEER_AND_VALUATION]]` + - `[[TRADING_AND_DIVIDEND]]` + - `[[RISK_SECTION]]` + - `[[APPENDIX]]` + +## 报告质量要求 + +- 报告必须包含模板中的所有一级章节 +- 必须同时覆盖: + - 公司基本信息与股权结构 + - 历史财务轨迹 + - 现金流与资产负债表 + - 市场预期与卖方摘录 + - 可比公司与估值定位 + - 交易表现与股东回报 + - 风险提示与口径说明 +- 报告必须达到首次覆盖的最低研究深度,不能退化成“财务 + peer 简表” +- 最低研究深度至少应回答 4 类问题: + - 公司是什么、处于什么行业位置、覆盖边界在哪里 + - 近 5 年财务轨迹和最近单季度经营节奏如何变化 + - 当前市场预期、卖方口径与可比估值把公司放在什么位置 + - 后续持续跟踪时最需要盯住哪些风险与验证点 +- 若启用了网络搜索结果补充,正文至少要出现“管理层 / 行业 / 政策 / 公司动态 / 竞争格局”中的一类实质信息,而不是只多一个 sidecar 文件 +- 不允许残留 `[[PLACEHOLDER]]` +- 不允许在正文中暴露内部流程描述、文件名、字段名或脚本术语 +- Markdown 输出必须与模板章节和数据契约保持一致 + +## 阶段验收清单 + +- [ ] Markdown 文件存在 +- [ ] 若本地渲染器存在则 HTML 文件存在 +- [ ] 模板占位符无残留 +- [ ] 主章节完整 +- [ ] 报告长度达到 10-16 页的最低可交付标准 +- [ ] 关键数据点与关键结论带 `数据来源:XXX,置信度X` +- [ ] peers 来自显式生成且可复核的可比公司池 +- [ ] 若使用卖方摘要,正文消费的是客户可读摘要层 +- [ ] 若使用 `web_search`,其内容已真正进入最终报告且未越权替代 RQData 主数据 + +## 常见错误 + +- 把 `financial-indicator` 返回字段错误地当成统一 `value` +- 在脚本里硬写固定 peers 或固定公司结论 +- 把原始卖方 `summary` 直接截断贴进客户稿 +- 把 `web_search` 结果写成主结论,反而压过 RQData 主数据 +- 在正文里出现文件名、JSON 字段名或内部执行语言 +- 使用固定日期、固定季度、固定输出路径 diff --git a/.cursor/skills/initiating-coverage/assets/template.md b/.cursor/skills/initiating-coverage/assets/template.md new file mode 100644 index 000000000..0582eb1e4 --- /dev/null +++ b/.cursor/skills/initiating-coverage/assets/template.md @@ -0,0 +1,34 @@ +# 首次覆盖研究报告 + +- 报告日期:[[REPORT_DATE]] +- 公司:[[COMPANY_NAME]]([[STOCK_CODE]]) + +## 执行摘要 +[[EXEC_SUMMARY]] + +## 公司概况与覆盖边界 +[[COMPANY_PROFILE]] + +## 股权结构与治理画像 +[[OWNERSHIP_SECTION]] + +## 历史财务轨迹 +[[FINANCIAL_TRAJECTORY]] + +## 盈利质量与现金流 +[[QUALITY_AND_CASHFLOW]] + +## 市场预期与卖方口径 +[[EXPECTATION_AND_SELLSIDE]] + +## 可比公司与估值定位 +[[PEER_AND_VALUATION]] + +## 交易表现与股东回报 +[[TRADING_AND_DIVIDEND]] + +## 风险提示 +[[RISK_SECTION]] + +## 附录:口径说明 +[[APPENDIX]] diff --git a/.cursor/skills/initiating-coverage/references/data_contract.md b/.cursor/skills/initiating-coverage/references/data_contract.md new file mode 100644 index 000000000..55c65ee15 --- /dev/null +++ b/.cursor/skills/initiating-coverage/references/data_contract.md @@ -0,0 +1,279 @@ +# initiating-coverage 数据契约 + +`initiating-coverage/scripts/generate_report.py` 默认从 `--data-dir` 读取以下 JSON 文件。 + +## 1. `company_info.json` + +典型字段: + +- `order_book_id` +- `symbol` +- `abbrev_symbol` +- `industry_name` +- `listed_date` +- `office_address` +- `province` +- `sector_code_name` + +用途: + +- 公司名称、上市时间、办公地、基础资料 + +## 2. `industry.json` + +典型字段: + +- `first_industry_name` +- `second_industry_name` +- `third_industry_name` + +用途: + +- 行业口径与 peers 选择锚点 + +## 3. `shares.json` + +典型字段: + +- `date` +- `total` +- `total_a` +- `circulation_a` +- `free_circulation` + +用途: + +- 总股本、流通股本、自由流通股本和自由流通比例 + +## 4. `shareholder_top10.json` + +典型字段: + +- `end_date` +- `info_date` +- `rank` +- `shareholder_name` +- `hold_percent_total` +- `hold_percent_float` +- `shareholder_kind` + +用途: + +- 十大股东结构、集中度、国资 / 基金 / 外资等持有人画像 + +## 5. `historical_financials.json` + +典型字段: + +- `quarter` +- `info_date` +- `revenue` +- `net_profit` +- `gross_profit` +- `total_assets` +- `total_liabilities` +- `total_equity` +- `cash_from_operating_activities` +- `cash_flow_from_investing_activities` +- `cash_flow_from_financing_activities` + +用途: + +- 自动识别最新财报季度 +- 生成 5 年同口径财务轨迹 +- 计算同比、单季度、毛利率、资产负债率、现金转化率 + +## 6. `roe_history.json` + +典型字段: + +- `date` +- `return_on_equity_weighted_average` + +用途: + +- ROE 时间序列与当前盈利质量 + +## 7. `market_cap.json` / `pe_ratio.json` / `pb_ratio.json` / `dividend_yield.json` + +典型字段: + +- `date` +- 对应 factor 字段 + +说明: + +- `dividend_yield` 原始值为 bps,生成报告时需要除以 `100` 后按百分比展示 + +用途: + +- 当前估值、股东回报与 peer percentile + +## 8. `price_history.json` + +典型字段: + +- `datetime` +- `close` +- `volume` +- `total_turnover` + +用途: + +- 1M / 3M / 6M / 1Y / 3Y 绝对收益 +- 相对基准收益 + +## 9. `turnover_history.json` + +典型字段: + +- `tradedate` +- `today` +- `week` +- `month` +- `year` + +用途: + +- 最新换手率与历史中位数对比 + +## 10. `benchmark_price.json` + +典型字段: + +- `datetime` +- `close` + +用途: + +- 计算相对沪深300等基准的超额收益 + +## 11. `dividend_history.json` + +典型字段: + +- `quarter` +- `dividend_cash_before_tax` +- `round_lot` +- `declaration_announcement_date` +- `ex_dividend_date` + +用途: + +- 分红历史、年度派现节奏与每手税前现金分配 + +## 12. `consensus.json` + +典型字段: + +- `date` +- `create_tm` +- `report_year_t` +- `comp_con_operating_revenue_t / t1 / t2 / t3` +- `comp_con_net_profit_t / t1 / t2 / t3` +- `comp_con_eps_t / t1 / t2 / t3` +- `con_targ_price` + +用途: + +- 最新一致预期与 60 天前预期对比 +- 目标价变化 + +## 13. `research_reports.json` + +典型字段: + +- `create_tm` +- `date` +- `report_title` +- `summary` +- `summaries.core_view` +- `institute` +- `author` +- `fiscal_year` +- `net_profit_t / t1 / t2` +- `eps_t / t1 / t2` +- `targ_price` +- `report_main_id` +- `data_source` + +用途: + +- 卖方口径摘要 +- 目标价、盈利预测与机构分布补充 + +补充说明: + +- 若存在 `data_source` 字段,应将 `0` 视为公司研报主样本;其他来源默认不进入最终正文 +- 最终报告优先读取 `summaries.core_view` 等客户可读摘要,不直接截断原始 `summary` + +## 14. `peer_pool.json` + +允许格式: + +```json +[ + { + "order_book_id": "600519.XSHG", + "selection_level": "third", + "market_cap": 2000000000000 + } +] +``` + +用途: + +- 显式记录可比公司池来源和选择结果 + +## 15. `peer_company_info.json` / `peer_industry.json` / `peer_latest_financials.json` + +用途: + +- 提供 peers 名称、行业归属、最新财务快照 + +## 16. `peer_roe.json` / `peer_market_cap.json` / `peer_pe_ratio.json` / `peer_pb_ratio.json` / `peer_dividend_yield.json` + +用途: + +- 生成可比公司估值与盈利质量对比表 + +## 17. `web_search_findings.json` + +该文件可选,仅用于补充管理层、行业、政策、竞争或公司近期动态的定性背景。 + +每条记录至少包含: + +- `query` +- `source_name` +- `source_type` +- `title` +- `url` +- `published_at` +- `retrieved_at` +- `summary` +- `why_relevant` +- `confidence` +- `finding_type` + +推荐附加字段: + +- `subject` +- `related_entities` +- `stance` + +说明: + +- `published_at` 是源内容发布时间,不是报告日期 +- `retrieved_at` 是实际检索时间 +- `source_type` / `confidence` 需遵守 [references/web_search.md](web_search.md) 的来源等级约束 +- `web_search_findings.json` 不能替代财务、估值、价格、分红、一致预期和 peer 量化主数据 +- 若该文件存在,报告会将其压缩为客户可读的补充背景,不会直接回显原始字段 + +## 解析约定 + +- 所有文件都允许 `{"data": [...]}`、`{"data": {...}}`、`[...]`、`{...}` 四种包装方式 +- 同一股票同一季度若存在多条财务记录,脚本会按 `info_date <= report-date` 选择最新披露版本 +- `consensus` 年份映射按 `report_year_t + offset` +- 研报必须做相关性过滤,标题或 `report_main_id` 优先,不能把纯行业周报直接塞进正文 +- `web_search_findings.json` 若存在,记录必须包含完整来源元数据,且置信度不能超过来源类别上限 +- 最终报告面向客户阅读,附录仅保留必要口径说明,不回显内部执行流程或文件名 diff --git a/.cursor/skills/initiating-coverage/references/web_search.md b/.cursor/skills/initiating-coverage/references/web_search.md new file mode 100644 index 000000000..c887bcf92 --- /dev/null +++ b/.cursor/skills/initiating-coverage/references/web_search.md @@ -0,0 +1,101 @@ +# Initiating Coverage Web Search Reference + +## Purpose + +Use `web_search` only to supplement qualitative information that `RQData CLI` does not directly provide for an initiating-coverage report. + +## Allowed Coverage + +- Management biographies, public career history, and governance events +- Industry size, competitive structure, policy environment, and regulatory changes +- Recent company product, capacity, organization, or partnership updates +- Competitor qualitative positioning that helps explain the peer set + +## Prohibited Usage + +- Do not replace financial statements, valuation multiples, prices, dividends, consensus, or peer ranking +- Do not use `web_search` to fabricate official disclosures or hard financial facts +- Do not let low-confidence external context dominate the core valuation or rating logic + +## Required Output File + +All external findings must be written to `web_search_findings.json`. + +Each record must contain: + +- `query` +- `source_name` +- `source_type` +- `title` +- `url` +- `published_at` +- `retrieved_at` +- `summary` +- `why_relevant` +- `confidence` +- `finding_type` + +Recommended fields: + +- `subject` +- `related_entities` +- `stance` + +## Allowed `finding_type` + +- `management_update` +- `industry_context` +- `policy_context` +- `company_news` +- `competition_context` + +## Source Types And Confidence Ceiling + +- `official`: max confidence `5` +- `government`: max confidence `4` +- `association`: max confidence `4` +- `authoritative_media`: max confidence `4` +- `general_news`: max confidence `3` +- `inference`: max confidence `1` + +## 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 as qualitative context for company research, industry framing, or governance interpretation. + +## 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 the qualitative context is unavailable or unverified + - keep the report at the structured-data level instead of pretending the research is complete + +## Example + +```json +{ + "data": [ + { + "query": "公司名 董事长 简历 2026", + "source_name": "公司官网", + "source_type": "official", + "title": "董事长简历", + "url": "https://www.example.com/management", + "published_at": "2026-03-01", + "retrieved_at": "2026-04-07", + "summary": "公司官网披露董事长曾在行业龙头和监管机构任职,拥有较长产业和管理经验。", + "why_relevant": "可用于补充管理层与治理画像章节的定性背景。", + "confidence": 5, + "finding_type": "management_update", + "subject": "董事长履历", + "related_entities": ["示例公司"], + "stance": "neutral" + } + ] +} +``` diff --git a/.cursor/skills/initiating-coverage/scripts/generate_report.py b/.cursor/skills/initiating-coverage/scripts/generate_report.py new file mode 100644 index 000000000..86de91ef0 --- /dev/null +++ b/.cursor/skills/initiating-coverage/scripts/generate_report.py @@ -0,0 +1,1565 @@ +#!/usr/bin/env python3 +"""Template-driven initiating coverage report generator.""" + +from __future__ import annotations + +import argparse +import json +import re +import shutil +import subprocess +from collections import Counter, defaultdict +from dataclasses import dataclass +from datetime import date, datetime, timedelta +from pathlib import Path +from statistics import median +from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple + + +TOKEN_RE = re.compile(r"\[\[([A-Z0-9_]+)\]\]") +REQUIRED_HEADINGS = [ + "# 首次覆盖研究报告", + "## 执行摘要", + "## 公司概况与覆盖边界", + "## 股权结构与治理画像", + "## 历史财务轨迹", + "## 盈利质量与现金流", + "## 市场预期与卖方口径", + "## 可比公司与估值定位", + "## 交易表现与股东回报", + "## 风险提示", + "## 附录:口径说明", +] +WEB_SOURCE_CONFIDENCE_CEILING = { + "official": 5, + "government": 4, + "association": 4, + "authoritative_media": 4, + "general_news": 3, + "inference": 1, +} +WEB_ALLOWED_FINDING_TYPES = { + "management_update", + "industry_context", + "policy_context", + "company_news", + "competition_context", +} + +TICKER_FIELDS = ("order_book_id", "ticker", "stock_code", "symbol", "code") +NAME_FIELDS = ("display_name", "name", "stock_name", "company_name", "symbol_name", "symbol") + + +@dataclass +class QuarterSnapshot: + quarter: str + info_date: date + revenue: Optional[float] + net_profit: Optional[float] + gross_profit: Optional[float] + operating_cash: Optional[float] + investing_cash: Optional[float] + financing_cash: Optional[float] + total_assets: Optional[float] + total_liabilities: Optional[float] + total_equity: Optional[float] + + +@dataclass +class QuarterDelta: + revenue: Optional[float] + net_profit: Optional[float] + gross_profit: Optional[float] + operating_cash: Optional[float] + investing_cash: Optional[float] + financing_cash: Optional[float] + + +def parse_args() -> argparse.Namespace: + skill_dir = Path(__file__).resolve().parent.parent + parser = argparse.ArgumentParser(description="生成模板驱动的首次覆盖研究报告") + parser.add_argument("--stock", required=True, help="股票代码") + parser.add_argument("--company", 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="输出 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: + 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 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[: len(fmt)], fmt).date() + except ValueError: + continue + try: + return datetime.strptime(text[:10], "%Y-%m-%d").date() + except ValueError: + return None + + +def parse_timestamp(value: Any) -> Optional[datetime]: + if value in (None, "", "null"): + return None + text = str(value).strip() + if not text: + return None + for fmt in ( + "%Y-%m-%d %H:%M:%S", + "%Y/%m/%d %H:%M:%S", + "%Y-%m-%dT%H:%M:%S", + "%Y-%m-%d", + ): + try: + return datetime.strptime(text[: len(fmt)], fmt) + except ValueError: + continue + 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 quarter_sort_key(value: str) -> Tuple[int, int]: + return parse_quarter_key(value) or (0, 0) + + +def previous_quarter(quarter: str) -> Optional[str]: + key = parse_quarter_key(quarter) + if not key: + return None + year, q = key + if q == 1: + return f"{year - 1}q4" + return f"{year}q{q - 1}" + + +def yoy_quarter(quarter: str) -> Optional[str]: + key = parse_quarter_key(quarter) + if not key: + return None + return f"{key[0] - 1}q{key[1]}" + + +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 float_or_none(value: Any) -> Optional[float]: + if value in (None, "", "null"): + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + +def billion_yuan_text(value: Optional[float]) -> str: + if value is None: + return "无数据" + return f"{value / 1e8:.2f}亿元" + + +def yuan_price_text(value: Optional[float], digits: int = 2) -> str: + if value is None: + return "无数据" + return f"{value:.{digits}f}元" + + +def shares_text(value: Optional[float]) -> str: + if value is None: + return "无数据" + return f"{value / 1e8:.2f}亿股" + + +def percent_text(value: Optional[float], digits: int = 1, signed: bool = True) -> str: + if value is None: + return "无数据" + sign = "+" if signed else "" + return f"{value:{sign}.{digits}f}%" + + +def x_text(value: Optional[float], digits: int = 2) -> str: + if value is None: + return "无数据" + return f"{value:.{digits}f}x" + + +def count_text(value: int) -> str: + return f"{value}家" + + +def ratio_or_none(numerator: Optional[float], denominator: Optional[float], multiplier: float = 100.0) -> Optional[float]: + if numerator is None or denominator in (None, 0): + return None + return numerator / denominator * multiplier + + +def safe_growth(current: Optional[float], base: Optional[float]) -> Optional[float]: + if current is None or base in (None, 0): + return None + return (current / base - 1.0) * 100.0 + + +def cagr(values: Sequence[Tuple[int, Optional[float]]]) -> Optional[float]: + usable = [(year, value) for year, value in values if value not in (None, 0)] + if len(usable) < 2: + return None + start_year, start_value = usable[0] + end_year, end_value = usable[-1] + periods = end_year - start_year + if periods <= 0 or start_value in (None, 0) or end_value is None: + return None + return ((end_value / start_value) ** (1 / periods) - 1.0) * 100.0 + + +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 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 shorten_text(text: Any, limit: int = 90) -> str: + if text in (None, "", "null"): + return "无摘要" + value = re.sub(r"\s+", " ", str(text)).strip() + if len(value) <= limit: + return value + return value[: limit - 1] + "…" + + +def clean_summary(text: Any) -> str: + return re.sub(r"\s+", " ", str(text or "")).strip() + + +def validate_web_search_records(records: Sequence[Any]) -> None: + if not records: + return + required_fields = { + "query", + "source_name", + "source_type", + "title", + "url", + "published_at", + "retrieved_at", + "summary", + "why_relevant", + "confidence", + "finding_type", + } + issues: List[str] = [] + for idx, item in enumerate(records, start=1): + if not isinstance(item, dict): + issues.append(f"第 {idx} 条网络搜索结果记录不是对象") + continue + missing = [field for field in required_fields if item.get(field) in (None, "", "null")] + if missing: + issues.append(f"第 {idx} 条网络搜索结果记录缺少字段:{', '.join(missing)}") + source_type = str(item.get("source_type") or "").strip() + if source_type not in WEB_SOURCE_CONFIDENCE_CEILING: + issues.append(f"第 {idx} 条网络搜索结果记录来源类型非法:{source_type or '空'}") + confidence = float_or_none(item.get("confidence")) + ceiling = WEB_SOURCE_CONFIDENCE_CEILING.get(source_type) + if confidence is None: + issues.append(f"第 {idx} 条网络搜索结果记录缺少置信度") + elif ceiling is not None and confidence > ceiling: + issues.append(f"第 {idx} 条网络搜索结果记录置信度 {confidence:g} 超过来源上限 {ceiling}") + finding_type = str(item.get("finding_type") or "").strip() + if finding_type not in WEB_ALLOWED_FINDING_TYPES: + issues.append(f"第 {idx} 条网络搜索结果记录 finding_type 非法:{finding_type or '空'}") + if issues: + raise ValueError("网络搜索结果校验失败:" + ";".join(issues)) + + +def format_web_context_lines(records: Sequence[Any], limit: int = 4) -> List[str]: + ranked: List[Dict[str, Any]] = [] + for item in records: + if not isinstance(item, dict): + continue + ranked.append( + { + "published_at": parse_iso_date(item.get("published_at")), + "source_name": str(item.get("source_name") or "网络搜索来源").strip(), + "title": clean_summary(item.get("title")), + "summary": clean_summary(item.get("summary")), + "why_relevant": clean_summary(item.get("why_relevant")), + "confidence": int(float_or_none(item.get("confidence")) or 0), + } + ) + ranked.sort(key=lambda item: (item["published_at"] or date.min, item["confidence"]), reverse=True) + lines: List[str] = [] + for item in ranked[:limit]: + published = item["published_at"].isoformat() if item["published_at"] else "日期未披露" + summary = item["summary"].rstrip("。;;!!??") + relevance = item["why_relevant"].rstrip("。;;!!??") + lines.append( + f"- `{published}` {item['source_name']}:{item['title']}。{summary}。与本次覆盖的关系:{relevance}。" + f" *数据来源:{item['source_name']},置信度{item['confidence']}*" + ) + return lines + + +def is_company_report_source(record: Dict[str, Any]) -> bool: + data_source = float_or_none(record.get("data_source")) + if data_source is None: + return True + return abs(data_source) < 1e-9 + + +def extract_research_display_summary(item: Dict[str, Any]) -> str: + summaries = item.get("summaries") if isinstance(item.get("summaries"), dict) else {} + for key in ("core_view", "report_summary", "investment_takeaway"): + value = summaries.get(key) + if value not in (None, ""): + return clean_summary(value) + for key in ("report_summary", "llm_summary"): + value = item.get(key) + if value not in (None, ""): + return clean_summary(value) + return "" + + +def dedupe_financial_records(records: List[Any], stock_ids: Sequence[str], report_date: date) -> 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() + info_date = parse_iso_date(item.get("info_date")) + if stock not in stock_set or not parse_quarter_key(quarter) or info_date is None or info_date > report_date: + continue + key = (stock, quarter) + current = deduped.get(key) + current_date = parse_iso_date(current.get("info_date")) if current else None + if current is None or (current_date is None or info_date >= current_date): + deduped[key] = item + return sorted(deduped.values(), key=lambda item: (normalize_ticker(item), quarter_sort_key(str(item.get("quarter") or "").lower()))) + + +def build_snapshot(record: Dict[str, Any]) -> QuarterSnapshot: + return QuarterSnapshot( + quarter=str(record.get("quarter") or "").lower(), + info_date=parse_iso_date(record.get("info_date")) or date.min, + revenue=float_or_none(record.get("revenue")), + net_profit=float_or_none(record.get("net_profit")), + gross_profit=float_or_none(record.get("gross_profit")), + operating_cash=float_or_none(record.get("cash_from_operating_activities")), + investing_cash=float_or_none(record.get("cash_flow_from_investing_activities")), + financing_cash=float_or_none(record.get("cash_flow_from_financing_activities")), + total_assets=float_or_none(record.get("total_assets")), + total_liabilities=float_or_none(record.get("total_liabilities")), + total_equity=float_or_none(record.get("total_equity")), + ) + + +def to_single_quarter(snapshot: QuarterSnapshot, prev_snapshot: Optional[QuarterSnapshot]) -> QuarterDelta: + key = parse_quarter_key(snapshot.quarter) + if key is None or key[1] == 1 or prev_snapshot is None: + return QuarterDelta( + revenue=snapshot.revenue, + net_profit=snapshot.net_profit, + gross_profit=snapshot.gross_profit, + operating_cash=snapshot.operating_cash, + investing_cash=snapshot.investing_cash, + financing_cash=snapshot.financing_cash, + ) + return QuarterDelta( + revenue=(snapshot.revenue - prev_snapshot.revenue) if snapshot.revenue is not None and prev_snapshot.revenue is not None else None, + net_profit=(snapshot.net_profit - prev_snapshot.net_profit) if snapshot.net_profit is not None and prev_snapshot.net_profit is not None else None, + gross_profit=(snapshot.gross_profit - prev_snapshot.gross_profit) if snapshot.gross_profit is not None and prev_snapshot.gross_profit is not None else None, + operating_cash=(snapshot.operating_cash - prev_snapshot.operating_cash) if snapshot.operating_cash is not None and prev_snapshot.operating_cash is not None else None, + investing_cash=(snapshot.investing_cash - prev_snapshot.investing_cash) if snapshot.investing_cash is not None and prev_snapshot.investing_cash is not None else None, + financing_cash=(snapshot.financing_cash - prev_snapshot.financing_cash) if snapshot.financing_cash is not None and prev_snapshot.financing_cash is not None else None, + ) + + +def latest_factor_value(records: List[Any], stock: str, field_name: str, report_date: date) -> Optional[float]: + best: Optional[Tuple[date, float]] = None + for item in records: + if not isinstance(item, dict) or normalize_ticker(item) != stock: + continue + event_date = parse_iso_date(item.get("date") or item.get("datetime")) + value = float_or_none(item.get(field_name)) + if field_name == "dividend_yield" and value is not None: + value = value / 100.0 + if event_date is None or event_date > report_date or value is None: + continue + if best is None or event_date >= best[0]: + best = (event_date, value) + return best[1] if best else None + + +def latest_factor_map(records: List[Any], stock_ids: Sequence[str], field_name: str, report_date: date) -> Dict[str, float]: + result = {} + for stock in stock_ids: + value = latest_factor_value(records, stock, field_name, report_date) + if value is not None: + result[stock] = value + return result + + +def build_close_points(records: List[Any], stock: str) -> List[Tuple[date, float, Optional[float]]]: + points = [] + for item in records: + if not isinstance(item, dict) or normalize_ticker(item) != stock: + continue + event_date = parse_iso_date(item.get("datetime") or item.get("date")) + close = float_or_none(item.get("close")) + turnover = float_or_none(item.get("total_turnover")) + if event_date is None or close is None: + continue + points.append((event_date, close, turnover)) + points.sort(key=lambda pair: pair[0]) + return points + + +def build_index_points(records: List[Any]) -> List[Tuple[date, float]]: + points = [] + for item in records: + if not isinstance(item, dict): + continue + event_date = parse_iso_date(item.get("datetime") or item.get("date")) + close = float_or_none(item.get("close")) + if event_date is None or close is None: + continue + points.append((event_date, close)) + points.sort(key=lambda pair: pair[0]) + return points + + +def build_turnover_points(records: List[Any], stock: str) -> List[Tuple[date, Optional[float], Optional[float], Optional[float]]]: + points = [] + for item in records: + if not isinstance(item, dict) or normalize_ticker(item) != stock: + continue + event_date = parse_iso_date(item.get("tradedate") or item.get("date")) + if event_date is None: + continue + points.append( + ( + event_date, + float_or_none(item.get("today")), + float_or_none(item.get("week")), + float_or_none(item.get("month")), + ) + ) + points.sort(key=lambda pair: pair[0]) + return points + + +def nearest_price_at_or_before(points: Sequence[Tuple[date, float]], target: date) -> Optional[Tuple[date, float]]: + chosen = None + for point_date, close in points: + if point_date <= target: + chosen = (point_date, close) + else: + break + return chosen + + +def trailing_return(points: Sequence[Tuple[date, float]], end_date: date, lookback_days: int) -> Optional[float]: + if not points: + return None + end_point = nearest_price_at_or_before(points, end_date) + start_point = nearest_price_at_or_before(points, end_date - timedelta(days=lookback_days)) + if end_point is None or start_point is None or start_point[1] == 0: + return None + return (end_point[1] / start_point[1] - 1.0) * 100.0 + + +def trailing_turnover_stats(points: Sequence[Tuple[date, Optional[float], Optional[float], Optional[float]]], end_date: date) -> Dict[str, Optional[float]]: + filtered = [point for point in points if point[0] <= end_date and point[1] is not None] + if not filtered: + return {"latest": None, "median_20": None, "median_60": None} + latest = filtered[-1][1] + values = [point[1] for point in filtered if point[1] is not None] + return { + "latest": latest, + "median_20": float(median(values[-20:])) if len(values) >= 1 else None, + "median_60": float(median(values[-60:])) if len(values) >= 1 else None, + } + + +def latest_share_row(records: List[Any], stock: str, report_date: date) -> Optional[Dict[str, Any]]: + best = None + best_date = None + for item in records: + if not isinstance(item, dict) or normalize_ticker(item) != stock: + continue + event_date = parse_iso_date(item.get("date")) + if event_date is None or event_date > report_date: + continue + if best_date is None or event_date >= best_date: + best = item + best_date = event_date + return best + + +def latest_top10(records: List[Any], stock: str, report_date: date) -> List[Dict[str, Any]]: + latest_key: Optional[Tuple[date, date]] = None + rows: List[Dict[str, Any]] = [] + for item in records: + if not isinstance(item, dict) or normalize_ticker(item) != stock: + continue + end_date = parse_iso_date(item.get("end_date")) + info_date = parse_iso_date(item.get("info_date")) + if end_date is None or info_date is None or info_date > report_date: + continue + key = (end_date, info_date) + if latest_key is None or key > latest_key: + latest_key = key + rows = [item] + elif key == latest_key: + rows.append(item) + rows.sort(key=lambda item: int(float_or_none(item.get("rank")) or 999)) + return rows + + +def choose_latest_record_by_cutoff(records: List[Any], cutoff: date) -> Optional[Dict[str, Any]]: + best: Optional[Tuple[datetime, Dict[str, Any]]] = None + for item in records: + if not isinstance(item, dict): + continue + event_dt = parse_timestamp(item.get("create_tm") or item.get("date")) + event_date = parse_iso_date(item.get("date") or item.get("create_tm")) + if event_dt is None or event_date is None or event_date > cutoff: + continue + if best is None or event_dt >= best[0]: + best = (event_dt, item) + return best[1] if best else None + + +def consensus_year_map(record: Optional[Dict[str, Any]]) -> Dict[int, Dict[str, Optional[float]]]: + if not record: + return {} + base_year = int(str(record.get("report_year_t") or date.today().year)) + year_map: Dict[int, Dict[str, Optional[float]]] = {} + for field_prefix, key in ( + ("comp_con_operating_revenue", "revenue"), + ("comp_con_net_profit", "profit"), + ("comp_con_eps", "eps"), + ): + for suffix, year in (("t", base_year), ("t1", base_year + 1), ("t2", base_year + 2), ("t3", base_year + 3)): + field_name = f"{field_prefix}_{suffix}" + if field_name not in record: + continue + year_map.setdefault(year, {})[key] = float_or_none(record.get(field_name)) + if "con_targ_price" in record: + year_map.setdefault(base_year, {})["target_price"] = float_or_none(record.get("con_targ_price")) + return year_map + + +def company_aliases(info_record: Optional[Dict[str, Any]], stock: str) -> List[str]: + aliases = [stock, stock.split(".")[0]] + if info_record: + symbol = str(info_record.get("symbol") or "").strip() + abbrev = str(info_record.get("abbrev_symbol") or "").strip() + if symbol: + aliases.extend([symbol, symbol.replace("股份", ""), symbol.replace("有限公司", "")]) + if abbrev: + aliases.append(abbrev) + result: List[str] = [] + seen = set() + for alias in aliases: + value = alias.strip() + if value and value not in seen: + seen.add(value) + result.append(value) + return result + + +def filter_related_reports(records: List[Any], aliases: Sequence[str], report_date: date) -> List[Dict[str, Any]]: + lower_aliases = [alias.lower() for alias in aliases] + stock_code = aliases[1].lower() if len(aliases) > 1 else "" + related = [] + for item in records: + if not isinstance(item, dict): + continue + if not is_company_report_source(item): + continue + create_date = parse_iso_date(item.get("create_tm") or item.get("date")) + if create_date is None or create_date > report_date or create_date < report_date - timedelta(days=210): + continue + title = str(item.get("report_title") or "") + summary = str(item.get("summary") or "") + report_main_id = str(item.get("report_main_id") or "").lower() + title_lower = title.lower() + summary_lower = summary.lower() + title_hit = any(alias in title_lower for alias in lower_aliases if len(alias) >= 2) + summary_hit = any(alias in summary_lower for alias in lower_aliases if len(alias) >= 4) + if title_hit or (stock_code and report_main_id == stock_code) or (summary_hit and any(token in title_lower for token in ("点评", "coverage", "initiation", "深度", "年报", "季报"))): + display_summary = extract_research_display_summary(item) + if not display_summary: + continue + related.append({**item, "_display_summary": display_summary}) + related.sort(key=lambda item: str(item.get("create_tm") or item.get("date") or ""), reverse=True) + return related + + +def latest_by_stock(records: List[Dict[str, Any]]) -> Dict[str, Dict[str, Any]]: + result: Dict[str, Dict[str, Any]] = {} + for item in records: + stock = normalize_ticker(item) + current = result.get(stock) + current_key = quarter_sort_key(str(current.get("quarter") or "").lower()) if current else (0, 0) + item_key = quarter_sort_key(str(item.get("quarter") or "").lower()) + if current is None or item_key >= current_key: + result[stock] = item + return result + + +def dominant_quarter(latest_records: Dict[str, Dict[str, Any]]) -> Optional[str]: + counts = Counter(str(item.get("quarter") or "").lower() for item in latest_records.values() if item.get("quarter")) + return counts.most_common(1)[0][0] if counts else None + + +def build_name_map(records: List[Any]) -> Dict[str, str]: + result = {} + for item in records: + if isinstance(item, dict): + stock = normalize_ticker(item) + if stock: + result[stock] = normalize_name(item) or stock + return result + + +def build_industry_map(records: List[Any]) -> Dict[str, Dict[str, str]]: + result = {} + for item in records: + if not isinstance(item, dict): + continue + stock = normalize_ticker(item) + if stock: + result[stock] = { + "first": str(item.get("first_industry_name") or "无数据"), + "second": str(item.get("second_industry_name") or "无数据"), + "third": str(item.get("third_industry_name") or "无数据"), + } + return result + + +def build_peer_rows( + stock_ids: Sequence[str], + instrument_records: List[Any], + industry_records: List[Any], + financial_records: List[Any], + roe_records: List[Any], + market_cap_records: List[Any], + pe_records: List[Any], + pb_records: List[Any], + dividend_records: List[Any], + report_date: date, +) -> Tuple[List[Dict[str, Any]], Optional[str]]: + names = build_name_map(instrument_records) + industries = build_industry_map(industry_records) + financial_deduped = dedupe_financial_records(financial_records, stock_ids, report_date) + latest_records = latest_by_stock(financial_deduped) + comparison_quarter = dominant_quarter(latest_records) + yoy_q = yoy_quarter(comparison_quarter or "") + by_stock_quarter = {(normalize_ticker(item), str(item.get("quarter") or "").lower()): item for item in financial_deduped} + + roe_map = latest_factor_map(roe_records, stock_ids, "return_on_equity_weighted_average", report_date) + market_cap_map = latest_factor_map(market_cap_records, stock_ids, "market_cap", report_date) + pe_map = latest_factor_map(pe_records, stock_ids, "pe_ratio", report_date) + pb_map = latest_factor_map(pb_records, stock_ids, "pb_ratio", report_date) + dividend_map = latest_factor_map(dividend_records, stock_ids, "dividend_yield", report_date) + + rows: List[Dict[str, Any]] = [] + for stock in stock_ids: + latest_record = latest_records.get(stock) + if latest_record is None: + continue + latest_quarter = str(latest_record.get("quarter") or "").lower() + if comparison_quarter and latest_quarter != comparison_quarter: + continue + yoy_record = by_stock_quarter.get((stock, yoy_q or "")) + revenue = float_or_none(latest_record.get("revenue")) + profit = float_or_none(latest_record.get("net_profit")) + gross_profit = float_or_none(latest_record.get("gross_profit")) + assets = float_or_none(latest_record.get("total_assets")) + liabilities = float_or_none(latest_record.get("total_liabilities")) + gross_margin = ratio_or_none(gross_profit, revenue) + debt_ratio = ratio_or_none(liabilities, assets) + rows.append( + { + "stock": stock, + "name": names.get(stock, stock), + "industry": industries.get(stock, {}), + "quarter": latest_quarter, + "revenue": revenue, + "profit": profit, + "revenue_yoy": safe_growth(revenue, float_or_none(yoy_record.get("revenue")) if yoy_record else None), + "profit_yoy": safe_growth(profit, float_or_none(yoy_record.get("net_profit")) if yoy_record else None), + "gross_margin": gross_margin, + "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), + "dividend_yield": dividend_map.get(stock), + } + ) + rows.sort(key=lambda item: (item.get("market_cap") is None, -(item.get("market_cap") or 0.0))) + return rows, comparison_quarter + + +def yearly_dividend_rows(records: List[Any], stock: str, report_date: date, limit: int = 5) -> List[Dict[str, Any]]: + grouped: Dict[int, Dict[str, Any]] = defaultdict(lambda: {"cash": 0.0, "events": 0, "round_lot": None, "latest_declaration": None}) + for item in records: + if not isinstance(item, dict) or normalize_ticker(item) != stock: + continue + declaration_date = parse_iso_date(item.get("declaration_announcement_date") or item.get("ex_dividend_date")) + if declaration_date is None or declaration_date > report_date: + continue + year = declaration_date.year + grouped[year]["cash"] += float_or_none(item.get("dividend_cash_before_tax")) or 0.0 + grouped[year]["events"] += 1 + grouped[year]["round_lot"] = item.get("round_lot") or grouped[year]["round_lot"] + if grouped[year]["latest_declaration"] is None or declaration_date > grouped[year]["latest_declaration"]: + grouped[year]["latest_declaration"] = declaration_date + rows = [] + for year in sorted(grouped.keys(), reverse=True)[:limit]: + payload = grouped[year] + rows.append( + { + "year": year, + "cash": payload["cash"], + "events": payload["events"], + "round_lot": payload["round_lot"], + "latest_declaration": payload["latest_declaration"], + } + ) + return rows + + +def validate_dataset( + latest_snapshot: Optional[QuarterSnapshot], + price_points: Sequence[Tuple[date, float, Optional[float]]], + consensus_records: List[Any], + peer_rows: Sequence[Dict[str, Any]], +) -> None: + issues = [] + if latest_snapshot is None: + issues.append("未识别到最新财报季度") + if len(price_points) < 120: + issues.append("价格历史覆盖不足") + if not consensus_records: + issues.append("一致预期数据为空") + if len(peer_rows) < 3: + issues.append("可比公司覆盖不足") + if issues: + raise ValueError("数据质量校验失败:" + ";".join(issues)) + + +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 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) + leftovers = sorted(set(TOKEN_RE.findall(report_text))) + if leftovers: + raise ValueError(f"模板占位符未完全替换:{', '.join(leftovers)}") + for heading in REQUIRED_HEADINGS: + if heading not in report_text: + raise ValueError(f"模板缺少必需章节:{heading}") + return report_text + + +def main() -> None: + args = parse_args() + report_date = date.fromisoformat(args.report_date) + data_dir = Path(args.data_dir).expanduser() + + company_records = extract_records(read_json_file(data_dir / "company_info.json")) + industry_records = extract_records(read_json_file(data_dir / "industry.json")) + share_records = extract_records(read_json_file(data_dir / "shares.json")) + shareholder_records = extract_records(read_json_file(data_dir / "shareholder_top10.json")) + financial_records = extract_records(read_json_file(data_dir / "historical_financials.json")) + roe_records = extract_records(read_json_file(data_dir / "roe_history.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")) + dividend_yield_records = extract_records(read_json_file(data_dir / "dividend_yield.json")) + price_records = extract_records(read_json_file(data_dir / "price_history.json")) + turnover_records = extract_records(read_json_file(data_dir / "turnover_history.json")) + benchmark_records = extract_records(read_json_file(data_dir / "benchmark_price.json")) + dividend_history_records = extract_records(read_json_file(data_dir / "dividend_history.json")) + consensus_records = extract_records(read_json_file(data_dir / "consensus.json")) + research_records = extract_records(read_json_file(data_dir / "research_reports.json")) + web_search_records = extract_records(read_json_file(data_dir / "web_search_findings.json")) + peer_pool_records = extract_records(read_json_file(data_dir / "peer_pool.json")) + peer_company_records = extract_records(read_json_file(data_dir / "peer_company_info.json")) + peer_industry_records = extract_records(read_json_file(data_dir / "peer_industry.json")) + peer_financial_records = extract_records(read_json_file(data_dir / "peer_latest_financials.json")) + peer_roe_records = extract_records(read_json_file(data_dir / "peer_roe.json")) + peer_market_cap_records = extract_records(read_json_file(data_dir / "peer_market_cap.json")) + peer_pe_records = extract_records(read_json_file(data_dir / "peer_pe_ratio.json")) + peer_pb_records = extract_records(read_json_file(data_dir / "peer_pb_ratio.json")) + peer_dividend_records = extract_records(read_json_file(data_dir / "peer_dividend_yield.json")) + + company_info = next((item for item in company_records if isinstance(item, dict) and normalize_ticker(item) == args.stock), None) + company_name = args.company or normalize_name(company_info or {}) or args.stock + industry_info = next((item for item in industry_records if isinstance(item, dict) and normalize_ticker(item) == args.stock), None) + + deduped_financials = dedupe_financial_records(financial_records, [args.stock], report_date) + snapshots = [build_snapshot(item) for item in deduped_financials] + latest_snapshot = snapshots[-1] if snapshots else None + snapshot_map = {snapshot.quarter: snapshot for snapshot in snapshots} + prev_snapshot = snapshot_map.get(previous_quarter(latest_snapshot.quarter) or "") if latest_snapshot else None + yoy_snapshot = snapshot_map.get(yoy_quarter(latest_snapshot.quarter) or "") if latest_snapshot else None + yoy_prev_snapshot = snapshot_map.get(previous_quarter(yoy_snapshot.quarter) or "") if yoy_snapshot else None + + price_points_raw = build_close_points(price_records, args.stock) + price_points = [(point_date, close) for point_date, close, _ in price_points_raw] + benchmark_points = build_index_points(benchmark_records) + turnover_points = build_turnover_points(turnover_records, args.stock) + + peer_ids = [] + seen_peer_ids = set() + for item in peer_pool_records: + if isinstance(item, str): + stock = item.strip() + elif isinstance(item, dict): + stock = normalize_ticker(item) + else: + continue + if stock and stock not in seen_peer_ids: + seen_peer_ids.add(stock) + peer_ids.append(stock) + if args.stock not in seen_peer_ids: + peer_ids.insert(0, args.stock) + + peer_rows, comparison_quarter = build_peer_rows( + peer_ids, + peer_company_records, + peer_industry_records, + peer_financial_records, + peer_roe_records, + peer_market_cap_records, + peer_pe_records, + peer_pb_records, + peer_dividend_records, + report_date, + ) + + validate_web_search_records(web_search_records) + validate_dataset(latest_snapshot, price_points_raw, consensus_records, peer_rows) + assert latest_snapshot is not None + + latest_single = to_single_quarter(latest_snapshot, prev_snapshot) + yoy_single = to_single_quarter(yoy_snapshot, yoy_prev_snapshot) if yoy_snapshot else QuarterDelta(None, None, None, None, None, None) + latest_market_cap = latest_factor_value(market_cap_records, args.stock, "market_cap", report_date) + latest_pe = latest_factor_value(pe_records, args.stock, "pe_ratio", report_date) + latest_pb = latest_factor_value(pb_records, args.stock, "pb_ratio", report_date) + latest_dividend_yield = latest_factor_value(dividend_yield_records, args.stock, "dividend_yield", report_date) + latest_roe = latest_factor_value(roe_records, args.stock, "return_on_equity_weighted_average", report_date) + latest_share = latest_share_row(share_records, args.stock, report_date) + latest_top10_rows = latest_top10(shareholder_records, args.stock, report_date) + aliases = company_aliases(company_info, args.stock) + related_reports = filter_related_reports(research_records, aliases, report_date) + + consensus_latest = choose_latest_record_by_cutoff(consensus_records, report_date) + consensus_prior = choose_latest_record_by_cutoff(consensus_records, report_date - timedelta(days=60)) + consensus_latest_map = consensus_year_map(consensus_latest) + consensus_prior_map = consensus_year_map(consensus_prior) + forecast_years = sorted( + year for year, payload in consensus_latest_map.items() if any(payload.get(key) is not None for key in ("revenue", "profit", "eps")) + )[:2] + if not forecast_years: + forecast_years = sorted(consensus_prior_map.keys())[:2] + + same_quarter_history = [] + latest_quarter_key = parse_quarter_key(latest_snapshot.quarter) + if latest_quarter_key: + target_q = latest_quarter_key[1] + for snapshot in snapshots: + key = parse_quarter_key(snapshot.quarter) + if key and key[1] == target_q: + same_quarter_history.append(snapshot) + same_quarter_history = same_quarter_history[-5:] + + revenue_cagr = cagr([(int(snapshot.quarter[:4]), snapshot.revenue) for snapshot in same_quarter_history]) + profit_cagr = cagr([(int(snapshot.quarter[:4]), snapshot.net_profit) for snapshot in same_quarter_history]) + gross_margin = ratio_or_none(latest_snapshot.gross_profit, latest_snapshot.revenue) + yoy_gross_margin = ratio_or_none(yoy_snapshot.gross_profit, yoy_snapshot.revenue) if yoy_snapshot else None + latest_single_gross_margin = ratio_or_none(latest_single.gross_profit, latest_single.revenue) + yoy_single_gross_margin = ratio_or_none(yoy_single.gross_profit, yoy_single.revenue) + debt_ratio = ratio_or_none(latest_snapshot.total_liabilities, latest_snapshot.total_assets) + yoy_debt_ratio = ratio_or_none(yoy_snapshot.total_liabilities, yoy_snapshot.total_assets) if yoy_snapshot else None + cash_conversion = ratio_or_none(latest_snapshot.operating_cash, latest_snapshot.net_profit, 1.0) + single_cash_conversion = ratio_or_none(latest_single.operating_cash, latest_single.net_profit, 1.0) + revenue_yoy = safe_growth(latest_snapshot.revenue, yoy_snapshot.revenue if yoy_snapshot else None) + profit_yoy = safe_growth(latest_snapshot.net_profit, yoy_snapshot.net_profit if yoy_snapshot else None) + revenue_qoq = safe_growth(latest_snapshot.revenue, prev_snapshot.revenue if prev_snapshot else None) + profit_qoq = safe_growth(latest_snapshot.net_profit, prev_snapshot.net_profit if prev_snapshot else None) + single_revenue_yoy = safe_growth(latest_single.revenue, yoy_single.revenue) + single_profit_yoy = safe_growth(latest_single.net_profit, yoy_single.net_profit) + + close_price = price_points[-1][1] if price_points else None + return_windows = [ + ("1M", 30), + ("3M", 90), + ("6M", 180), + ("1Y", 365), + ("3Y", 365 * 3), + ] + stock_returns = {label: trailing_return(price_points, report_date, days) for label, days in return_windows} + benchmark_returns = {label: trailing_return(benchmark_points, report_date, days) for label, days in return_windows} + excess_returns = { + label: (stock_returns[label] - benchmark_returns[label]) + if stock_returns[label] is not None and benchmark_returns[label] is not None + else None + for label, _ in return_windows + } + turnover_stats = trailing_turnover_stats(turnover_points, report_date) + + top10_total = sum(float_or_none(item.get("hold_percent_total")) or 0.0 for item in latest_top10_rows) + top3_total = sum(float_or_none(item.get("hold_percent_total")) or 0.0 for item in latest_top10_rows[:3]) + top1_total = float_or_none(latest_top10_rows[0].get("hold_percent_total")) if latest_top10_rows else None + total_shares = float_or_none((latest_share or {}).get("total")) + circulation_a = float_or_none((latest_share or {}).get("circulation_a")) + free_circulation = float_or_none((latest_share or {}).get("free_circulation")) + free_float_ratio = ratio_or_none(free_circulation, total_shares) + circulation_ratio = ratio_or_none(circulation_a, total_shares) + + peer_market_cap_median = median_or_none(row.get("market_cap") for row in peer_rows) + peer_pe_median = median_or_none(row.get("pe") for row in peer_rows) + peer_pb_median = median_or_none(row.get("pb") for row in peer_rows) + peer_dividend_median = median_or_none(row.get("dividend_yield") for row in peer_rows) + peer_roe_median = median_or_none(row.get("roe") for row in peer_rows) + target_peer_row = next((row for row in peer_rows if row["stock"] == args.stock), None) + + latest_target_price = float_or_none((consensus_latest or {}).get("con_targ_price")) + prior_target_price = float_or_none((consensus_prior or {}).get("con_targ_price")) + target_price_delta = safe_growth(latest_target_price, prior_target_price) + target_upside = safe_growth(latest_target_price, close_price) + + same_quarter_rows = [] + for snapshot in same_quarter_history: + roe_point = latest_factor_value(roe_records, args.stock, "return_on_equity_weighted_average", snapshot.info_date) + same_quarter_rows.append( + [ + snapshot.quarter, + snapshot.info_date.isoformat(), + billion_yuan_text(snapshot.revenue), + billion_yuan_text(snapshot.net_profit), + percent_text(ratio_or_none(snapshot.gross_profit, snapshot.revenue), signed=False), + percent_text(ratio_or_none(snapshot.total_liabilities, snapshot.total_assets), signed=False), + percent_text(roe_point, signed=False), + billion_yuan_text(snapshot.operating_cash), + ] + ) + + latest_compare_rows = [ + [ + "累计口径", + latest_snapshot.quarter, + billion_yuan_text(latest_snapshot.revenue), + billion_yuan_text(latest_snapshot.net_profit), + percent_text(revenue_yoy), + percent_text(profit_yoy), + percent_text(gross_margin, signed=False), + x_text(cash_conversion), + ], + [ + "单季度口径", + latest_snapshot.quarter, + billion_yuan_text(latest_single.revenue), + billion_yuan_text(latest_single.net_profit), + percent_text(single_revenue_yoy), + percent_text(single_profit_yoy), + percent_text(latest_single_gross_margin, signed=False), + x_text(single_cash_conversion), + ], + ] + + ownership_rows = [ + [ + str(item.get("rank") or "无数据"), + str(item.get("shareholder_name") or "无数据"), + str(item.get("shareholder_kind") or "无数据"), + percent_text(float_or_none(item.get("hold_percent_total")), signed=False), + percent_text(float_or_none(item.get("hold_percent_float")), signed=False), + ] + for item in latest_top10_rows[:10] + ] + + consensus_table_rows = [] + for year in forecast_years: + latest_payload = consensus_latest_map.get(year, {}) + prior_payload = consensus_prior_map.get(year, {}) + consensus_table_rows.append( + [ + str(year), + billion_yuan_text(latest_payload.get("revenue")), + percent_text(safe_growth(latest_payload.get("revenue"), prior_payload.get("revenue"))), + billion_yuan_text(latest_payload.get("profit")), + percent_text(safe_growth(latest_payload.get("profit"), prior_payload.get("profit"))), + str(latest_payload.get("eps")) if latest_payload.get("eps") is not None else "无数据", + ] + ) + + report_rows = [] + for item in related_reports[:6]: + report_rows.append( + [ + str(parse_iso_date(item.get("create_tm") or item.get("date")) or "无数据"), + str(item.get("institute") or "无数据"), + shorten_text(item.get("report_title"), 38), + yuan_price_text(float_or_none(item.get("targ_price"))), + billion_yuan_text(float_or_none(item.get("net_profit_t"))), + shorten_text(item.get("_display_summary"), 72), + ] + ) + + peer_table_rows = [] + for row in peer_rows[:8]: + peer_table_rows.append( + [ + row["name"], + row["stock"], + row.get("quarter") or "无数据", + billion_yuan_text(row.get("market_cap")), + billion_yuan_text(row.get("revenue")), + billion_yuan_text(row.get("profit")), + percent_text(row.get("gross_margin"), signed=False), + percent_text(row.get("roe"), signed=False), + x_text(row.get("pe")), + x_text(row.get("pb")), + percent_text(row.get("dividend_yield"), signed=False), + ] + ) + + performance_rows = [] + for label, _ in return_windows: + performance_rows.append( + [ + label, + percent_text(stock_returns[label]), + percent_text(benchmark_returns[label]), + percent_text(excess_returns[label]), + ] + ) + + dividend_rows = [] + for row in yearly_dividend_rows(dividend_history_records, args.stock, report_date, limit=5): + round_lot = str(row["round_lot"] or "10") + dividend_rows.append( + [ + str(row["year"]), + f"{row['cash']:.2f} 元 / 每{round_lot}股", + str(row["events"]), + row["latest_declaration"].isoformat() if row["latest_declaration"] else "无数据", + ] + ) + + peer_rank_by_market_cap = next((idx + 1 for idx, row in enumerate(peer_rows) if row["stock"] == args.stock), None) + peer_rank_by_pe = next( + (idx + 1 for idx, row in enumerate(sorted(peer_rows, key=lambda item: (item.get("pe") is None, -(item.get("pe") or 0.0)))) if row["stock"] == args.stock), + None, + ) + peer_rank_by_roe = next( + (idx + 1 for idx, row in enumerate(sorted(peer_rows, key=lambda item: (item.get("roe") is None, -(item.get("roe") or 0.0)))) if row["stock"] == args.stock), + None, + ) + + risk_rows = [ + [ + "净利润一致预期 60 天变化", + percent_text(safe_growth( + consensus_latest_map.get(forecast_years[0], {}).get("profit") if forecast_years else None, + consensus_prior_map.get(forecast_years[0], {}).get("profit") if forecast_years else None, + )), + "-5%", + "关注" if (forecast_years and safe_growth( + consensus_latest_map.get(forecast_years[0], {}).get("profit"), + consensus_prior_map.get(forecast_years[0], {}).get("profit"), + ) or 0.0) <= -5 else "正常", + ], + [ + "经营现金流 / 净利润", + x_text(cash_conversion), + "<0.8x", + "关注" if cash_conversion is not None and cash_conversion < 0.8 else "正常", + ], + [ + "资产负债率同比变化", + percent_text((debt_ratio - yoy_debt_ratio) if debt_ratio is not None and yoy_debt_ratio is not None else None), + ">+5pct", + "关注" if debt_ratio is not None and yoy_debt_ratio is not None and debt_ratio - yoy_debt_ratio > 5 else "正常", + ], + [ + "PE 相对 peer 中位数", + percent_text(safe_growth(latest_pe, peer_pe_median)), + ">+20%", + "关注" if latest_pe is not None and peer_pe_median not in (None, 0) and latest_pe > peer_pe_median * 1.2 else "正常", + ], + [ + "1Y 相对基准超额收益", + percent_text(excess_returns["1Y"]), + "<-10%", + "关注" if excess_returns["1Y"] is not None and excess_returns["1Y"] < -10 else "正常", + ], + [ + "第一大股东持股", + percent_text(top1_total, signed=False), + ">50%", + "关注" if top1_total is not None and top1_total > 50 else "正常", + ], + ] + + ownership_highlights = [] + for item in latest_top10_rows[:10]: + ownership_highlights.append( + f"- 第 {str(item.get('rank') or '无数据')} 大股东为 `{str(item.get('shareholder_name') or '无数据')}`,股东类别 `{str(item.get('shareholder_kind') or '无数据')}`,占总股本约 {percent_text(float_or_none(item.get('hold_percent_total')), signed=False)}。" + ) + + history_fact_lines = [] + for snapshot in same_quarter_history: + history_fact_lines.append( + f"- `{snapshot.quarter}`:收入 {billion_yuan_text(snapshot.revenue)},净利润 {billion_yuan_text(snapshot.net_profit)},毛利率 {percent_text(ratio_or_none(snapshot.gross_profit, snapshot.revenue), signed=False)},经营现金流 {billion_yuan_text(snapshot.operating_cash)}。" + ) + + quarter_fact_lines = [] + for snapshot in snapshots[-8:]: + prev_for_snapshot = snapshot_map.get(previous_quarter(snapshot.quarter) or "") + yoy_for_snapshot = snapshot_map.get(yoy_quarter(snapshot.quarter) or "") + single_snapshot = to_single_quarter(snapshot, prev_for_snapshot) + quarter_fact_lines.append( + f"- `{snapshot.quarter}` 累计收入 / 净利润为 {billion_yuan_text(snapshot.revenue)} / {billion_yuan_text(snapshot.net_profit)},单季度收入 / 净利润约为 {billion_yuan_text(single_snapshot.revenue)} / {billion_yuan_text(single_snapshot.net_profit)},累计同比约 {percent_text(safe_growth(snapshot.revenue, yoy_for_snapshot.revenue if yoy_for_snapshot else None))} / {percent_text(safe_growth(snapshot.net_profit, yoy_for_snapshot.net_profit if yoy_for_snapshot else None))}。" + ) + + expectation_fact_lines = [] + for year in forecast_years: + latest_payload = consensus_latest_map.get(year, {}) + prior_payload = consensus_prior_map.get(year, {}) + expectation_fact_lines.append( + f"- `{year}` 年一致预期收入 / 净利润 / EPS 分别为 {billion_yuan_text(latest_payload.get('revenue'))} / {billion_yuan_text(latest_payload.get('profit'))} / {str(latest_payload.get('eps')) if latest_payload.get('eps') is not None else '无数据'},较 60 天前变化约 {percent_text(safe_growth(latest_payload.get('revenue'), prior_payload.get('revenue')))} / {percent_text(safe_growth(latest_payload.get('profit'), prior_payload.get('profit')))}。" + ) + + report_fact_lines = [] + for item in related_reports[:6]: + report_fact_lines.append( + f"- `{str(parse_iso_date(item.get('create_tm') or item.get('date')) or '无数据')}` `{str(item.get('institute') or '无数据')}` 发布 `{shorten_text(item.get('report_title'), 42)}`,目标价 {yuan_price_text(float_or_none(item.get('targ_price')))},`net_profit_t` {billion_yuan_text(float_or_none(item.get('net_profit_t')))},核心观点 `{shorten_text(item.get('_display_summary'), 140)}`。" + ) + + peer_fact_lines = [] + for row in peer_rows[:6]: + peer_fact_lines.append( + f"- `{row['name']}`(`{row['stock']}`)最新可比季度 `{row.get('quarter') or '无数据'}`,市值 {billion_yuan_text(row.get('market_cap'))},收入 / 净利润 {billion_yuan_text(row.get('revenue'))} / {billion_yuan_text(row.get('profit'))},ROE {percent_text(row.get('roe'), signed=False)},PE / PB {x_text(row.get('pe'))} / {x_text(row.get('pb'))}。" + ) + + performance_fact_lines = [] + for label, _ in return_windows: + performance_fact_lines.append( + f"- `{label}`:公司收益 {percent_text(stock_returns[label])},基准收益 {percent_text(benchmark_returns[label])},超额收益 {percent_text(excess_returns[label])}。" + ) + + dividend_fact_lines = [] + for row in yearly_dividend_rows(dividend_history_records, args.stock, report_date, limit=5): + dividend_fact_lines.append( + f"- `{row['year']}` 年累计税前派现约 {row['cash']:.2f} 元 / 每{str(row['round_lot'] or '10')}股,分红事件 {row['events']} 次,最新宣告日 `{row['latest_declaration'].isoformat() if row['latest_declaration'] else '无数据'}`。" + ) + + risk_fact_lines = [ + f"- 净利润一致预期 60 天变化约为 {percent_text(safe_growth(consensus_latest_map.get(forecast_years[0], {}).get('profit') if forecast_years else None, consensus_prior_map.get(forecast_years[0], {}).get('profit') if forecast_years else None))}。", + f"- 经营现金流 / 净利润约为 {x_text(cash_conversion)},资产负债率约为 {percent_text(debt_ratio, signed=False)}。", + f"- PE 相对 peer 中位数偏离约 {percent_text(safe_growth(latest_pe, peer_pe_median))},PB 相对 peer 中位数偏离约 {percent_text(safe_growth(latest_pb, peer_pb_median))}。", + f"- 过去 1Y / 3Y 相对基准超额收益约为 {percent_text(excess_returns['1Y'])} / {percent_text(excess_returns['3Y'])}。", + f"- 第一大股东 / 前三大 / 前十大持股比例约为 {percent_text(top1_total, signed=False)} / {percent_text(top3_total, signed=False)} / {percent_text(top10_total, signed=False)}。", + ] + + industry_text = " - ".join( + str(industry_info.get(field) or "") + for field in ("first_industry_name", "second_industry_name", "third_industry_name") + if industry_info and industry_info.get(field) + ) or "未提供" + + peer_pool_rows = [] + for item in peer_pool_records[:8]: + if not isinstance(item, dict): + continue + peer_pool_rows.append( + [ + normalize_ticker(item) or "无数据", + str(item.get("selection_level") or "无数据"), + billion_yuan_text(float_or_none(item.get("market_cap"))), + ] + ) + + exec_summary = "\n".join( + [ + f"- 公司 **{company_name}**(`{args.stock}`)位于 `{industry_text}`,上市日期 `{str((company_info or {}).get('listed_date') or '无数据')}`;当前识别的最新财报季度为 `{latest_snapshot.quarter}`,披露日 `{latest_snapshot.info_date.isoformat()}`。", + f"- 最新累计口径收入 / 净利润分别为 {billion_yuan_text(latest_snapshot.revenue)} / {billion_yuan_text(latest_snapshot.net_profit)},同比 {percent_text(revenue_yoy)} / {percent_text(profit_yoy)};单季度口径收入 / 净利润约为 {billion_yuan_text(latest_single.revenue)} / {billion_yuan_text(latest_single.net_profit)}。", + f"- 当前总市值 {billion_yuan_text(latest_market_cap)},PE / PB / 股息率分别为 {x_text(latest_pe)} / {x_text(latest_pb)} / {percent_text(latest_dividend_yield, signed=False)};过去 1Y / 3Y 股价收益约为 {percent_text(stock_returns['1Y'])} / {percent_text(stock_returns['3Y'])}。", + f"- 可比公司池覆盖 {count_text(len(peer_rows))},可比口径季度为 `{comparison_quarter or latest_snapshot.quarter}`;公司当前市值 / ROE / PE 在可比样本中的排名约为 `{peer_rank_by_market_cap or '无数据'}` / `{peer_rank_by_roe or '无数据'}` / `{peer_rank_by_pe or '无数据'}`。", + f"- 最新一致预期目标价 {yuan_price_text(latest_target_price)},相对最新收盘价的静态空间约为 {percent_text(target_upside)};60 天目标价变化约为 {percent_text(target_price_delta)}。", + f"- 最新十大股东口径下,前三大 / 前十大持股合计约 {percent_text(top3_total, signed=False)} / {percent_text(top10_total, signed=False)},自由流通股占总股本比例约 {percent_text(free_float_ratio, signed=False)}。", + "", + "*数据来源:RQData,置信度5*", + ] + ) + + external_context_lines = format_web_context_lines(web_search_records) + company_profile_parts = [ + "本章节聚焦覆盖边界与样本口径,帮助确认公司定位、比较框架与后续章节的分析边界。", + f"公司基础档案显示,其上市于 `{str((company_info or {}).get('listed_date') or '无数据')}`,办公地址 `{str((company_info or {}).get('office_address') or '无数据')}`,所在省份 `{str((company_info or {}).get('province') or '无数据')}`,行业标签 `{industry_text}`。这些字段用于确认覆盖对象与可比样本锚点。", + "", + format_table( + ["项目", "内容"], + [ + ["公司名称", company_name], + ["股票代码", args.stock], + ["行业归属", industry_text], + ["上市日期", str((company_info or {}).get("listed_date") or "无数据")], + ["办公地址", str((company_info or {}).get("office_address") or "无数据")], + ["省份", str((company_info or {}).get("province") or "无数据")], + ["最新财报季度", latest_snapshot.quarter], + ["财报披露日", latest_snapshot.info_date.isoformat()], + ["当前收盘价", yuan_price_text(close_price)], + ["当前总市值", billion_yuan_text(latest_market_cap)], + ], + ), + "", + f"可比公司按照统一筛选规则生成:优先选择与目标公司同三级行业的上市公司,样本不足时再扩展到二级行业,最终保留 {count_text(len(peer_rows))} 只代表性样本,并按最新可得市值排序。", + f"当前样本的可比季度为 `{comparison_quarter or latest_snapshot.quarter}`,这样估值与盈利对比尽量保持在同一财务口径下。", + "", + format_table(["股票代码", "选择层级", "最新市值"], peer_pool_rows or [["无数据", "无数据", "无数据"]]), + ] + if external_context_lines: + company_profile_parts.extend( + [ + "", + "补充行业 / 管理层 / 公司动态语境如下:", + *external_context_lines, + ] + ) + company_profile_parts.extend( + [ + "", + "*数据来源:RQData,置信度5*", + ] + ) + company_profile = "\n".join(company_profile_parts) + + ownership_section = "\n".join( + [ + f"最新股本记录日期为 `{str((latest_share or {}).get('date') or '无数据')}`。总股本 / A 股流通股 / 自由流通股分别为 {shares_text(total_shares)} / {shares_text(circulation_a)} / {shares_text(free_circulation)},流通比例 / 自由流通比例约为 {percent_text(circulation_ratio, signed=False)} / {percent_text(free_float_ratio, signed=False)}。", + f"最新十大股东口径对应的报告期末为 `{str((latest_top10_rows[0].get('end_date')) if latest_top10_rows else '无数据')}`,前三大 / 前十大持股集中度约为 {percent_text(top3_total, signed=False)} / {percent_text(top10_total, signed=False)},第一大股东单独持股约为 {percent_text(top1_total, signed=False)}。", + "以下内容仅复述最新股东结构,不附加治理优劣判断:", + *ownership_highlights, + "", + format_table(["排名", "股东名称", "股东类别", "占总股本", "占流通股"], ownership_rows or [["无数据", "无数据", "无数据", "无数据", "无数据"]]), + "", + format_table( + ["指标", "当前值"], + [ + ["总股本", shares_text(total_shares)], + ["A 股流通股", shares_text(circulation_a)], + ["自由流通股", shares_text(free_circulation)], + ["流通比例", percent_text(circulation_ratio, signed=False)], + ["自由流通比例", percent_text(free_float_ratio, signed=False)], + ["前三大股东合计", percent_text(top3_total, signed=False)], + ["前十大股东合计", percent_text(top10_total, signed=False)], + ], + ), + "", + "*数据来源:RQData,置信度5*", + ] + ) + + financial_trajectory = "\n".join( + [ + f"财务轨迹采用与最新季度同口径的历史序列进行横向比较。当前最新季度为 `{latest_snapshot.quarter}`,以下表格展示近 5 个同口径季度的收入、利润、毛利率、资产负债率、ROE 与经营现金流。", + "逐年同口径事实如下:", + *history_fact_lines, + "", + format_table( + ["季度", "披露日", "收入", "净利润", "毛利率", "资产负债率", "ROE", "经营现金流"], + same_quarter_rows or [["无数据", "无数据", "无数据", "无数据", "无数据", "无数据", "无数据", "无数据"]], + ), + "", + f"基于同口径序列,收入 CAGR 约为 {percent_text(revenue_cagr)},净利润 CAGR 约为 {percent_text(profit_cagr)}。最新累计口径收入 / 净利润同比约为 {percent_text(revenue_yoy)} / {percent_text(profit_yoy)},环比约为 {percent_text(revenue_qoq)} / {percent_text(profit_qoq)}。", + f"若拆到单季度口径,收入同比约 {percent_text(single_revenue_yoy)},净利润同比约 {percent_text(single_profit_yoy)};这一组数据更适合观察短期经营节奏是否出现变化。", + "最近 8 个已披露季度的累计/单季度口径串联如下:", + *quarter_fact_lines, + "", + format_table( + ["口径", "季度", "收入", "净利润", "收入同比", "净利润同比", "毛利率", "经营现金流/净利润"], + latest_compare_rows, + ), + "", + "*数据来源:RQData,置信度5*", + ] + ) + + quality_and_cashflow = "\n".join( + [ + f"盈利质量章节只保留结构化指标。累计口径下,毛利率约 {percent_text(gross_margin, signed=False)},较上年同期变动 {percent_text((gross_margin - yoy_gross_margin) if gross_margin is not None and yoy_gross_margin is not None else None)};资产负债率约 {percent_text(debt_ratio, signed=False)},同比变动 {percent_text((debt_ratio - yoy_debt_ratio) if debt_ratio is not None and yoy_debt_ratio is not None else None)}。", + f"经营现金流 / 净利润约为 {x_text(cash_conversion)},单季度口径约为 {x_text(single_cash_conversion)};最新 ROE 约 {percent_text(latest_roe, signed=False)},peer 中位数约 {percent_text(peer_roe_median, signed=False)}。", + f"累计经营 / 投资 / 融资现金流分别为 {billion_yuan_text(latest_snapshot.operating_cash)} / {billion_yuan_text(latest_snapshot.investing_cash)} / {billion_yuan_text(latest_snapshot.financing_cash)};单季度对应值分别为 {billion_yuan_text(latest_single.operating_cash)} / {billion_yuan_text(latest_single.investing_cash)} / {billion_yuan_text(latest_single.financing_cash)}。", + f"总资产 / 总负债 / 股东权益分别为 {billion_yuan_text(latest_snapshot.total_assets)} / {billion_yuan_text(latest_snapshot.total_liabilities)} / {billion_yuan_text(latest_snapshot.total_equity)},可用于交叉判断资本结构与现金生成质量。", + "", + format_table( + ["指标", "累计口径", "单季度口径"], + [ + ["营业收入", billion_yuan_text(latest_snapshot.revenue), billion_yuan_text(latest_single.revenue)], + ["净利润", billion_yuan_text(latest_snapshot.net_profit), billion_yuan_text(latest_single.net_profit)], + ["毛利", billion_yuan_text(latest_snapshot.gross_profit), billion_yuan_text(latest_single.gross_profit)], + ["经营现金流", billion_yuan_text(latest_snapshot.operating_cash), billion_yuan_text(latest_single.operating_cash)], + ["投资现金流", billion_yuan_text(latest_snapshot.investing_cash), billion_yuan_text(latest_single.investing_cash)], + ["融资现金流", billion_yuan_text(latest_snapshot.financing_cash), billion_yuan_text(latest_single.financing_cash)], + ["总资产", billion_yuan_text(latest_snapshot.total_assets), "不适用"], + ["总负债", billion_yuan_text(latest_snapshot.total_liabilities), "不适用"], + ["股东权益", billion_yuan_text(latest_snapshot.total_equity), "不适用"], + ], + ), + "", + format_table( + ["质量指标", "当前值", "对比基准"], + [ + ["毛利率", percent_text(gross_margin, signed=False), percent_text(yoy_gross_margin, signed=False)], + ["单季度毛利率", percent_text(latest_single_gross_margin, signed=False), percent_text(yoy_single_gross_margin, signed=False)], + ["资产负债率", percent_text(debt_ratio, signed=False), percent_text(yoy_debt_ratio, signed=False)], + ["ROE", percent_text(latest_roe, signed=False), percent_text(peer_roe_median, signed=False)], + ["经营现金流 / 净利润", x_text(cash_conversion), "1.00x"], + ], + ), + "", + "*数据来源:RQData,置信度5*", + ] + ) + + expectation_and_sellside = "\n".join( + [ + "市场预期章节展示一致预期时间序列与相关卖方材料摘要,重点观察盈利预测、目标价与机构表述的变化方向。", + *expectation_fact_lines, + "", + format_table( + ["预测年份", "最新收入预期", "较60天前", "最新净利润预期", "较60天前", "最新 EPS"], + consensus_table_rows or [["无数据", "无数据", "无数据", "无数据", "无数据", "无数据"]], + ), + "", + f"最新一致预期目标价为 {yuan_price_text(latest_target_price)},较 60 天前变动 {percent_text(target_price_delta)};相对最新收盘价的静态空间约为 {percent_text(target_upside)}。当前纳入正文的卖方材料仅保留与公司直接相关、且已整理成客户可读摘要的样本。", + "近期相关卖方观点如下:", + *report_fact_lines, + "", + format_table( + ["日期", "机构", "标题", "目标价", "净利润t", "摘要摘录"], + report_rows or [["无数据", "无数据", "无数据", "无数据", "无数据", "无数据"]], + ), + "", + "*数据来源:RQData,置信度5*", + ] + ) + + peer_and_valuation = "\n".join( + [ + f"可比公司章节使用统一规则筛选出的样本与最新同口径财务快照。当前可比季度为 `{comparison_quarter or latest_snapshot.quarter}`,公司当前总市值 / PE / PB / 股息率分别为 {billion_yuan_text(latest_market_cap)} / {x_text(latest_pe)} / {x_text(latest_pb)} / {percent_text(latest_dividend_yield, signed=False)},对应可比样本中位数为 {billion_yuan_text(peer_market_cap_median)} / {x_text(peer_pe_median)} / {x_text(peer_pb_median)} / {percent_text(peer_dividend_median, signed=False)}。", + f"若以当前样本排序,公司在可比样本中的市值排名约 `{peer_rank_by_market_cap or '无数据'}`,ROE 排名约 `{peer_rank_by_roe or '无数据'}`,PE 排名约 `{peer_rank_by_pe or '无数据'}`。以下表格用于观察公司在盈利质量、估值和回报维度上的相对位置。", + "主要可比公司事实摘录:", + *peer_fact_lines, + "", + format_table( + ["公司", "代码", "季度", "市值", "收入", "净利润", "毛利率", "ROE", "PE", "PB", "股息率"], + peer_table_rows or [["无数据"] * 11], + ), + "", + format_table( + ["指标", "公司当前", "Peer 中位数", "相对中位数"], + [ + ["总市值", billion_yuan_text(latest_market_cap), billion_yuan_text(peer_market_cap_median), percent_text(safe_growth(latest_market_cap, peer_market_cap_median))], + ["ROE", percent_text(latest_roe, signed=False), percent_text(peer_roe_median, signed=False), percent_text((latest_roe - peer_roe_median) if latest_roe is not None and peer_roe_median is not None else None)], + ["PE", x_text(latest_pe), x_text(peer_pe_median), percent_text(safe_growth(latest_pe, peer_pe_median))], + ["PB", x_text(latest_pb), x_text(peer_pb_median), percent_text(safe_growth(latest_pb, peer_pb_median))], + ["股息率", percent_text(latest_dividend_yield, signed=False), percent_text(peer_dividend_median, signed=False), percent_text((latest_dividend_yield - peer_dividend_median) if latest_dividend_yield is not None and peer_dividend_median is not None else None)], + ], + ), + "", + "*数据来源:RQData,置信度5*", + ] + ) + + trading_and_dividend = "\n".join( + [ + f"交易表现章节覆盖 1M/3M/6M/1Y/3Y 的绝对收益与相对基准收益。最新收盘价为 {yuan_price_text(close_price)},最新换手率约 {percent_text(turnover_stats['latest'], signed=False)},近 20 / 60 个交易日换手率中位数约为 {percent_text(turnover_stats['median_20'], signed=False)} / {percent_text(turnover_stats['median_60'], signed=False)}。", + "区间收益分解如下:", + *performance_fact_lines, + "", + format_table( + ["区间", "公司收益", "基准收益", "超额收益"], + performance_rows, + ), + "", + format_table( + ["换手指标", "数值"], + [ + ["最新日换手率", percent_text(turnover_stats["latest"], signed=False)], + ["近20日中位数", percent_text(turnover_stats["median_20"], signed=False)], + ["近60日中位数", percent_text(turnover_stats["median_60"], signed=False)], + [ + "最新 / 近60日中位数", + x_text((turnover_stats["latest"] / turnover_stats["median_60"]) if turnover_stats["latest"] is not None and turnover_stats["median_60"] not in (None, 0) else None), + ], + ], + ), + "", + "近五年分红记录摘要:", + *dividend_fact_lines, + "", + format_table( + ["年份", "累计税前派现", "事件数", "最新宣告日"], + dividend_rows or [["无数据", "无数据", "无数据", "无数据"]], + ), + "", + "*数据来源:RQData,置信度5*", + ] + ) + + risk_section = "\n".join( + [ + "以下内容为重点跟踪信号表,用于提示需要持续核查的维度,并不替代对公司基本面的完整判断。", + *risk_fact_lines, + "", + format_table( + ["监控项", "当前值", "提示阈值", "状态"], + risk_rows, + ), + "", + "*数据来源:RQData,置信度5*", + ] + ) + + appendix = "\n".join( + [ + f"- 报告日期:`{report_date.isoformat()}`;目标股票:`{args.stock}`;当前识别财报季度:`{latest_snapshot.quarter}`;可比样本数量:`{len(peer_rows)}`。", + "- 若某项数据缺失,报告会明确展示“无数据/未提供”。", + "- 研报样本优先保留公司直接相关材料;标题不相关、来源不匹配或未形成客户可读摘要的样本不会进入正文。", + "- 可比公司表、收益表和风险信号表均基于同一报告日口径生成,适合作为后续持续跟踪的对照基线。", + "", + "*数据来源:RQData,置信度5*", + ] + ) + + template_text = Path(args.template).read_text(encoding="utf-8") + replacements = { + "REPORT_DATE": report_date.isoformat(), + "COMPANY_NAME": company_name, + "STOCK_CODE": args.stock, + "EXEC_SUMMARY": exec_summary, + "COMPANY_PROFILE": company_profile, + "OWNERSHIP_SECTION": ownership_section, + "FINANCIAL_TRAJECTORY": financial_trajectory, + "QUALITY_AND_CASHFLOW": quality_and_cashflow, + "EXPECTATION_AND_SELLSIDE": expectation_and_sellside, + "PEER_AND_VALUATION": peer_and_valuation, + "TRADING_AND_DIVIDEND": trading_and_dividend, + "RISK_SECTION": risk_section, + "APPENDIX": appendix, + } + report_text = render_template(template_text, replacements) + + output_path = Path(args.output).expanduser() if args.output else data_dir / f"initiating_coverage_{args.stock}_{report_date.isoformat()}.md" + 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() diff --git a/.cursor/skills/morning-note/SKILL.md b/.cursor/skills/morning-note/SKILL.md new file mode 100644 index 000000000..d380487db --- /dev/null +++ b/.cursor/skills/morning-note/SKILL.md @@ -0,0 +1,333 @@ +--- +name: rq-morning-note +description: | + 创建模板驱动的晨会纪要报告,汇总隔夜公告、最近披露的财务更新、昨日股价表现、盘前宏观/行业语境与今日重点观察名单。 + `RQData CLI` 负责个股行情、公告、财报、分红等结构化主数据;若需要补充宏观政策、海外市场、行业新闻或监管变化,可额外使用 `web_search` 获取实时信息,并先落为结构化 JSON,再由当前脚本纳入最终正文。 + + 务必使用此技能当用户: + - 明确请求晨会纪要、morning note、晨会准备、morning meeting + - 想了解隔夜动态、昨晚发生了什么、overnight developments + - 询问今天看什么、今日重点、what to watch today + - 需要盘前关注名单或交易观察 + + 不适用场景: + - 单一公司财报深度点评 -> earnings-analysis / earnings-preview + - 首次覆盖深度研究 -> initiating-coverage + - 仅需简单新闻摘要且不需要完整报告 +compatibility: "Requires rqdata CLI, Bash, python3. Report structure is managed by morning-note/assets/template.md." +--- + +# RQ 股票研究 - 晨会纪要 + +## 核心原则 + +- 所有内容必须遵循三阶段流程:数据采集 -> 报告生成 -> HTML 渲染 +- `assets/template.md` 是唯一报告模板来源;Python 只做数据归一化、占位符填充和结构校验 +- 晨会纪要必须先完整收集数据,再形成盘前判断,不能边抓数据边写结论 +- 不能写死股票池、日期、板块、公司名称、交易结论 +- 不能伪造具体盘中时间点;如果数据里没有明确时间,只能写“今日重点关注” +- 缺少数据时必须明确写“无数据/无事件/未验证”,不能留空章节 + +## 数据源分工 + +### `RQData CLI` 负责 + +- 覆盖股票池、公司名称与基础元数据 +- 个股价格、成交额、基准指数表现 +- 公司公告、财报披露、分红事项 + +### `web_search` 负责 + +- 隔夜宏观政策、海外市场、监管动态 +- 行业新闻、商品与主题链条的实时语境 +- RQData 无法直接提供的盘前网络搜索结果 + +### `web_search` 禁止替代的内容 + +- 个股价格、基准指数、公告、财报、分红 +- 股票池定义、相对强弱排序 +- 任何本应由 RQData 提供的结构化金融主数据 + +## `web_search` 使用规则 + +详细字段、来源等级、落盘示例和 fallback 规则见 [references/web_search.md](references/web_search.md)。 + +允许补充的内容: + +- 宏观与监管动态 +- 海外市场与大宗商品线索 +- 行业或主题链最新消息 + +落盘要求: + +- 所有网络搜索结果必须先写入 `web_search_findings.json` +- 只写结构化记录,不把搜索草稿直接写进报告 +- 若未提供该文件,晨会仍可交付,但要保持“结构化盘前纪要”边界 +- 即便提供了 `web_search_findings.json`,它也只能补充盘前语境,不能覆盖 RQData 事实层 + +## 硬性规则 + +以下任一条违反,视为输出失败: + +- `[MUST-1]` 先完整收集所有数据,再开始分析和写报告 +- `[MUST-2]` 个股行情、基准、公告、财报和分红必须来自 `RQData CLI` +- `[MUST-3]` 宏观、行业和海外市场等实时网络搜索结果必须通过 `web_search` 获取,不能靠训练记忆补写 +- `[MUST-4]` 晨会纪要必须给出明确盘前观点,只总结事件数量视为失败 +- `[MUST-5]` 晨会结论必须保持盘前边界,不能伪造盘中时间、成交确认或收盘结论 +- `[MUST-6]` 每个关键数据点或关键结论都要标 `数据来源:XXX,置信度X` +- `[MUST-7]` 客户稿不得暴露 `LLM`、`skill`、文件名、JSON 字段名或内部 workflow 术语 +- `[MUST-8]` 若高优先级公告存在原文链接,正文应尽量保留追溯入口 +- `[MUST-9]` 低置信度网络搜索结果不能单独支撑交易结论 + +## 确信度评级 + +- `5`:RQData CLI、上市公司公告、交易所披露、公司官网 +- `4`:政府 / 监管 / 行业协会 / 官方机构、权威财经媒体 +- `3`:一般新闻源,但来源清晰且与其他来源一致 +- `2`:单一来源、细节不完整、时点未充分验证 +- `1`:推断、估算、未验证信息 + +使用规则: + +- 混合结论的置信度取关键来源中的最低等级 +- 网络搜索结果若无法确认原始出处,不能标高置信度 +- 低置信度信息只能作为“关注线索”,不能直接写成“盘前结论” + +## 图表 / 图片需求 + +晨会纪要当前以短文本和表格为主,不强制图表交付,但仍需明确最小可用的结构化表达。 + +- 市场回顾:至少保留覆盖池涨跌幅表和基准对照 +- 今日重点关注:至少保留 3-5 条可执行关注事项 +- 若未来增加图表,优先补充指数/覆盖池相对表现图,不得以图代替事实说明 + +## 目标产出 + +- 报告长度:2-3 页 +- 目标字数:800-1,500 字 +- 输出文件: + - Markdown 报告 + - HTML 报告(若本地已安装渲染器) +- 输出目录必须由 `--data-dir` / `--output` 指定,不能写死固定路径 + +## 目录结构 + +```text +morning-note/ +├── SKILL.md +├── scripts/ +│ └── generate_report.py +├── assets/ +│ └── template.md +└── references/ + ├── data_contract.md + └── web_search.md +``` + +## 输入文件契约 + +原始数据目录由 `--data-dir` 指定,脚本会按下列文件名查找输入: + +- `stock_pool.json` +- `instrument_meta.json` +- `latest_earnings.json` +- `price_recent.json` +- `hs300_recent.json` +- `dividend_news.json` +- `announcement_raw.json` +- `web_search_findings.json`(可选) + +完整字段说明见 [references/data_contract.md](references/data_contract.md)。 + +## 工作流 + +### 步骤 1:准备参数 + +```bash +REPORT_DATE="${REPORT_DATE:-$(date +%F)}" +LOOKBACK_START="$(python3 - < "$DATA_DIR/stock_pool.json" + +rqdata stock cn instruments --payload "{ + \"order_book_ids\": $ORDER_BOOK_IDS_JSON +}" --format json > "$DATA_DIR/instrument_meta.json" + +rqdata stock cn financial --payload "{ + \"order_book_ids\": $ORDER_BOOK_IDS_JSON, + \"fields\": [\"revenue\", \"net_profit\"], + \"start_quarter\": \"$FINANCIAL_START_QUARTER\", + \"end_quarter\": \"$FINANCIAL_END_QUARTER\", + \"statements\": \"all\" +}" --format json > "$DATA_DIR/latest_earnings.json" + +rqdata stock cn price --payload "{ + \"order_book_ids\": $ORDER_BOOK_IDS_JSON, + \"start_date\": \"$PRICE_LOOKBACK_START\", + \"end_date\": \"$REPORT_DATE\", + \"fields\": [\"close\", \"total_turnover\"], + \"adjust_type\": \"post\" +}" --format json > "$DATA_DIR/price_recent.json" + +rqdata index price --payload "{ + \"order_book_ids\": [\"000300.XSHG\"], + \"start_date\": \"$PRICE_LOOKBACK_START\", + \"end_date\": \"$REPORT_DATE\", + \"fields\": [\"close\"] +}" --format json > "$DATA_DIR/hs300_recent.json" + +rqdata stock cn dividend --payload "{ + \"order_book_ids\": $ORDER_BOOK_IDS_JSON, + \"start_date\": \"$DIVIDEND_LOOKBACK_START\", + \"end_date\": \"$REPORT_DATE\" +}" --format json > "$DATA_DIR/dividend_news.json" + +rqdata stock cn announcement --payload "{ + \"order_book_ids\": $ORDER_BOOK_IDS_JSON, + \"start_date\": \"$LOOKBACK_START\", + \"end_date\": \"$REPORT_DATE\" +}" --format json > "$DATA_DIR/announcement_raw.json" +``` + +说明: + +- `latest_earnings.json` 建议抓取近 1-2 年全部季度,脚本会按 `report_date` 过滤到隔夜窗口 +- `price_recent.json` 与 `hs300_recent.json` 需要至少包含两个交易日,脚本才可计算涨跌幅 +- `announcement_raw.json` 是隔夜动态的首选来源 +- 缺少某个文件时脚本会跳过对应模块,但不会伪造内容 + +### 步骤 2.5:可选的宏观 / 行业 / 海外市场补充 + +当用户需要更完整的盘前语境时,可执行该步骤。 + +- 使用 `web_search` 补充宏观政策、监管动态、海外市场、商品或行业新闻 +- 结果必须写入 `web_search_findings.json` +- 这类信息只补充盘前语境,不替代个股公告、财报和价格事实 + +### 步骤 3:生成 Markdown 报告 + +```bash +python3 morning-note/scripts/generate_report.py \ + --data-dir "$DATA_DIR" \ + --report-date "$REPORT_DATE" \ + --lookback-start "$LOOKBACK_START" \ + --output "$OUTPUT_MD" +``` + +### 步骤 4:渲染 HTML + +脚本会优先尝试调用本地安装的 `rq-report-renderer`,若未安装则回退到仓库内 `report-renderer/scripts/render_report.py`;两者都不可用时保留 Markdown 并打印警告。 + +## 阶段门控 + +### Gate 1:结构化主数据齐备 + +- 股票池、公司名、价格和基准数据存在 +- 公告、财报、分红数据至少有一类可用 +- 晨会正文不依赖网络搜索结果也能交付 + +### Gate 2:盘前判断准备完成 + +- 已完成昨日市场回顾和隔夜事件筛选 +- 执行摘要能回答“核心观点 / 盘前定位 / 持仓建议” +- 今日重点关注来自真实事件或真实相对强弱 + +### Gate 3:可选网络搜索结果完成 + +- 若启用网络搜索结果补充,`web_search_findings.json` 已落盘 +- 字段完整、来源等级可解释 +- 网络搜索结果仅用于宏观 / 行业 / 政策 / 海外市场信息 + +### Gate 4:成稿完成 + +- Markdown 已生成 +- 若本地渲染器存在,HTML 已生成 +- 客户稿不暴露内部术语 +- 长度、章节、来源标注和盘前观点均达标 + +## 模板规则 + +- 报告必须严格基于 [template.md](assets/template.md) 生成 +- 占位符采用 `[[TOKEN]]` 语法,不使用 Jinja +- 当前模板仅允许以下占位符: + - `[[REPORT_DATE]]` + - `[[AS_OF_TIME]]` + - `[[LOOKBACK_START]]` + - `[[COVERAGE_SCOPE]]` + - `[[EXEC_SUMMARY]]` + - `[[OVERNIGHT_DEVELOPMENTS]]` + - `[[MARKET_RECAP]]` + - `[[WATCHLIST]]` + - `[[TRADE_OBSERVATIONS]]` + - `[[RISK_ALERTS]]` + - `[[APPENDIX]]` + +## 报告质量要求 + +- 完整包含模板中的主章节 +- `执行摘要` 必须能落到“核心观点 / 盘前定位 / 持仓建议”层面,不能只是事件计数 +- 隔夜动态必须优先引用真实公告/财报/分红记录 +- 若启用 `web_search`,正文必须真实吸收网络搜索结果,而不是只多一个 sidecar JSON +- 股价回顾必须基于真实价格数据,不能写固定涨跌幅示例 +- 今日关注必须来自真实事件或真实相对强弱,不得伪造盘中时间 +- 风险提示必须来自真实数据覆盖情况、市场强弱或事件集中度,不能写空泛套话 +- 不得残留模板示例值、股票池示例值、旧路径或内部字段名 + +## 阶段验收清单 + +- [ ] Markdown 文件存在 +- [ ] 若本地渲染器存在则 HTML 文件存在 +- [ ] 模板占位符无残留 +- [ ] 主章节完整 +- [ ] 数据来源说明存在 +- [ ] 执行摘要包含核心观点 / 盘前定位 / 持仓建议 +- [ ] 若高优先级公告存在原文链接,正文保留了追溯入口 +- [ ] 若使用 `web_search`,其内容已真正进入最终报告且未越权替代 RQData 主数据 + +## 常见错误 + +- 直接在 Python 中写死整篇晨会正文 +- 把固定股票池、固定板块叙述当作通用逻辑 +- 伪造 `09:30 / 10:00 / 14:00` 等盘中时间点 +- 把网络搜索结果写成既成事实,却没有结构化来源落盘 +- 在正文中暴露文件名、JSON 字段名或内部执行语言 +- 数据不足时输出空标题或模板示例文本 diff --git a/.cursor/skills/morning-note/assets/template.md b/.cursor/skills/morning-note/assets/template.md new file mode 100644 index 000000000..080f87ea6 --- /dev/null +++ b/.cursor/skills/morning-note/assets/template.md @@ -0,0 +1,27 @@ +# 晨会纪要 + +报告日期:[[REPORT_DATE]] +信息截面:[[AS_OF_TIME]] +隔夜观察区间:[[LOOKBACK_START]] 至 [[REPORT_DATE]] +覆盖范围:[[COVERAGE_SCOPE]] + +## 执行摘要 +[[EXEC_SUMMARY]] + +## 隔夜动态 +[[OVERNIGHT_DEVELOPMENTS]] + +## 昨日市场回顾 +[[MARKET_RECAP]] + +## 今日重点关注 +[[WATCHLIST]] + +## 交易观察 +[[TRADE_OBSERVATIONS]] + +## 风险提示 +[[RISK_ALERTS]] + +## 附录:口径说明 +[[APPENDIX]] diff --git a/.cursor/skills/morning-note/references/data_contract.md b/.cursor/skills/morning-note/references/data_contract.md new file mode 100644 index 000000000..7404903d0 --- /dev/null +++ b/.cursor/skills/morning-note/references/data_contract.md @@ -0,0 +1,154 @@ +# morning-note 数据契约 + +`morning-note/scripts/generate_report.py` 默认从 `--data-dir` 读取以下 JSON 文件。 + +## 1. `stock_pool.json` + +允许格式: + +```json +{ + "data": ["600519.XSHG", "300750.XSHE"] +} +``` + +用途: + +- 明确盘前覆盖股票池 +- 与元数据文件交叉校验公司名称 + +## 2. `instrument_meta.json` + +典型字段: + +- `order_book_id` +- `symbol` +- `abbrev_symbol` +- `listed_date` +- `sector_code_name` + +用途: + +- 映射股票代码与公司简称 +- 生成覆盖范围文字 + +## 3. `latest_earnings.json` + +典型字段: + +- `order_book_id` +- `quarter` 或 `report_period` +- `info_date` 或 `report_date` +- `revenue` +- `net_profit` + +用途: + +- 识别隔夜窗口内的财报披露 +- 为执行摘要和重点跟踪项提供财务事实 + +补充说明: + +- 晨会纪要只消费隔夜窗口内已披露的记录,不能把更早的财务数据写成“隔夜更新” + +## 4. `price_recent.json` + +典型字段: + +- `order_book_id` +- `datetime` +- `close` +- `total_turnover` + +用途: + +- 回顾昨日股价表现 +- 识别相对强弱个股 + +补充说明: + +- 至少需要两个交易日观察值才能计算涨跌幅 + +## 5. `hs300_recent.json` + +典型字段: + +- `order_book_id` +- `datetime` +- `close` + +用途: + +- 计算沪深300基准涨跌幅 +- 给覆盖池相对强弱提供参考基线 + +## 6. `dividend_news.json` + +典型字段: + +- `order_book_id` +- `announcement_date` +- `ex_dividend_date` +- `book_closure_date` +- `payable_date` +- `dividend_cash_before_tax` +- `cash_dividend_per_share` + +用途: + +- 识别新披露分红信息 +- 标记临近除权除息事项 + +## 7. `announcement_raw.json` + +典型字段: + +- `order_book_id` +- `title` / `announcement_title` / `info_name` +- `announcement_date` / `ann_date` / `pub_date` / `info_date` +- `announcement_link` +- `info_type` +- `media` + +用途: + +- 识别隔夜重点公告 +- 为盘前关注名单保留原始追溯链接 + +补充说明: + +- 若存在 `announcement_link`,高优先级事项应在正文中保留客户可点击链接 +- 客户稿可以保留原文链接,但不能暴露内部字段名 + +## 8. `web_search_findings.json` + +该文件可选,仅用于补充宏观、政策、海外市场、行业新闻和监管动态。 + +每条记录至少包含: + +- `query` +- `source_name` +- `source_type` +- `title` +- `url` +- `published_at` +- `retrieved_at` +- `summary` +- `why_relevant` +- `confidence` +- `finding_type` + +推荐附加字段: + +- `subject` +- `related_entities` + +用途: + +- 补充 RQData 无法直接提供的盘前宏观和行业语境 +- 支持执行摘要里的“盘前定位”与“今日重点关注” + +限制: + +- `web_search_findings.json` 不能替代个股行情、公告、财报和分红等结构化主数据 +- 若未提供该文件,晨会仍可交付,但需保持“结构化盘前纪要”边界,不能伪造实时新闻 diff --git a/.cursor/skills/morning-note/references/web_search.md b/.cursor/skills/morning-note/references/web_search.md new file mode 100644 index 000000000..5f2c4e328 --- /dev/null +++ b/.cursor/skills/morning-note/references/web_search.md @@ -0,0 +1,98 @@ +# Morning Note Web Search Reference + +## Purpose + +Use `web_search` only to supplement macro, policy, overseas market, commodity, and industry context that `RQData CLI` does not directly provide for a morning-note report. + +## Allowed Coverage + +- Macro and regulatory updates +- Overseas market developments and major policy signals +- Industry and theme-chain news relevant to the covered stocks +- Commodity or supply-chain context that helps explain sector sentiment + +## Prohibited Usage + +- Do not replace stock prices, benchmark moves, announcements, earnings, or dividend records +- Do not use `web_search` to fabricate company disclosures or hard financial facts +- Do not let low-confidence external context dominate the morning call + +## Required Output File + +All external findings must be written to `web_search_findings.json`. + +Each record must contain: + +- `query` +- `source_name` +- `source_type` +- `title` +- `url` +- `published_at` +- `retrieved_at` +- `summary` +- `why_relevant` +- `confidence` +- `finding_type` + +Recommended fields: + +- `subject` +- `related_entities` + +## Allowed `finding_type` + +- `macro_context` +- `policy_context` +- `industry_context` +- `global_market_context` +- `commodity_context` + +## Source Types And Confidence Ceiling + +- `official`: max confidence `5` +- `government`: max confidence `4` +- `association`: max confidence `4` +- `authoritative_media`: max confidence `4` +- `general_news`: max confidence `3` +- `inference`: max confidence `1` + +## 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 tied to the morning call. +5. Use the findings only as context for the overnight view, watchlist, and risk framing. + +## 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 the external context is unavailable or unverified + - keep the report at the structured-data level instead of pretending the morning note is complete + +## Example + +```json +{ + "data": [ + { + "query": "央行 逆回购 2026-04-07", + "source_name": "中国人民银行", + "source_type": "government", + "title": "公开市场业务交易公告", + "url": "https://www.example.com/pboc", + "published_at": "2026-04-07", + "retrieved_at": "2026-04-07", + "summary": "央行披露当日公开市场操作规模和利率安排。", + "why_relevant": "可用于补充盘前流动性与风险偏好判断。", + "confidence": 4, + "finding_type": "policy_context", + "subject": "流动性操作" + } + ] +} +``` diff --git a/.cursor/skills/morning-note/scripts/generate_report.py b/.cursor/skills/morning-note/scripts/generate_report.py new file mode 100644 index 000000000..6d0434a08 --- /dev/null +++ b/.cursor/skills/morning-note/scripts/generate_report.py @@ -0,0 +1,1223 @@ +#!/usr/bin/env python3 +"""Template-driven morning note report generator.""" + +from __future__ import annotations + +import argparse +import json +import re +import shutil +import subprocess +from collections import defaultdict +from dataclasses import dataclass +from datetime import date, datetime, timedelta +from pathlib import Path +from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple + + +TOKEN_RE = re.compile(r"\[\[([A-Z0-9_]+)\]\]") +REQUIRED_HEADINGS = [ + "# 晨会纪要", + "## 执行摘要", + "## 隔夜动态", + "## 昨日市场回顾", + "## 今日重点关注", + "## 交易观察", + "## 风险提示", + "## 附录:口径说明", +] +WEB_SOURCE_CONFIDENCE_CEILING = { + "official": 5, + "government": 4, + "association": 4, + "authoritative_media": 4, + "general_news": 3, + "inference": 1, +} +WEB_ALLOWED_FINDING_TYPES = { + "macro_context", + "policy_context", + "industry_context", + "global_market_context", + "commodity_context", +} + +TICKER_FIELDS = ("order_book_id", "ticker", "stock_code", "symbol", "code") +NAME_FIELDS = ("display_name", "name", "stock_name", "company_name", "symbol_name", "symbol") +TITLE_FIELDS = ("title", "announcement_title", "info_name", "name", "summary") +ANNOUNCEMENT_DATE_FIELDS = ("announcement_date", "ann_date", "pub_date", "info_date", "datetime", "date") +DIVIDEND_DATE_FIELDS = ("announcement_date", "ex_dividend_date", "book_closure_date", "payable_date", "date") + + +@dataclass +class EarningsItem: + ticker: str + company: str + quarter: str + report_date: date + revenue: Optional[float] + net_profit: Optional[float] + + +@dataclass +class AnnouncementItem: + ticker: str + company: str + title: str + event_date: date + category: str + announcement_link: Optional[str] + info_type: str + media: str + + +@dataclass +class DividendItem: + ticker: str + company: str + event_label: str + event_date: date + amount_note: str + + +@dataclass +class PriceMove: + ticker: str + company: str + last_close: float + change_pct: float + turnover: Optional[float] + + +@dataclass +class ExternalFinding: + source_name: str + title: str + published_at: date + summary: str + why_relevant: str + confidence: int + finding_type: str + + +def parse_args() -> argparse.Namespace: + skill_dir = Path(__file__).resolve().parent.parent + parser = argparse.ArgumentParser(description="生成模板驱动的晨会纪要报告") + 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("--lookback-start", help="隔夜观察起始日,默认报告日前 1 天") + parser.add_argument("--output", 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 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 + + candidates = [ + "%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", + ] + for fmt in candidates: + try: + return datetime.strptime(text, fmt).date() + except ValueError: + continue + + text = text[:10] + try: + return datetime.strptime(text, "%Y-%m-%d").date() + except ValueError: + return None + + +def parse_iso_datetime(value: Any) -> Optional[datetime]: + if value in (None, "", "null"): + return None + if isinstance(value, datetime): + return value + if isinstance(value, date): + return datetime.combine(value, datetime.min.time()) + + text = str(value).strip() + if not text: + return None + + candidates = [ + "%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", + "%Y-%m-%d", + "%Y/%m/%d", + ] + for fmt in candidates: + try: + return datetime.strptime(text, fmt) + except ValueError: + continue + return None + + +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_title(record: Dict[str, Any]) -> str: + value = pick_first(record, TITLE_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 normalize_link(value: Any) -> Optional[str]: + if value in (None, "", "null"): + return None + text = str(value).strip() + return text or None + + +def clean_text(value: Any) -> str: + return re.sub(r"\s+", " ", str(value or "")).strip() + + +def validate_web_search_records(records: Sequence[Any]) -> None: + if not records: + return + required_fields = { + "query", + "source_name", + "source_type", + "title", + "url", + "published_at", + "retrieved_at", + "summary", + "why_relevant", + "confidence", + "finding_type", + } + issues: List[str] = [] + for idx, item in enumerate(records, start=1): + if not isinstance(item, dict): + issues.append(f"第 {idx} 条网络搜索结果记录不是对象") + continue + missing = [field for field in required_fields if item.get(field) in (None, "", "null")] + if missing: + issues.append(f"第 {idx} 条网络搜索结果记录缺少字段:{', '.join(missing)}") + source_type = str(item.get("source_type") or "").strip() + if source_type not in WEB_SOURCE_CONFIDENCE_CEILING: + issues.append(f"第 {idx} 条网络搜索结果记录来源类型非法:{source_type or '空'}") + confidence = float_or_none(item.get("confidence")) + ceiling = WEB_SOURCE_CONFIDENCE_CEILING.get(source_type) + if confidence is None: + issues.append(f"第 {idx} 条网络搜索结果记录缺少置信度") + elif ceiling is not None and confidence > ceiling: + issues.append(f"第 {idx} 条网络搜索结果记录置信度 {confidence:g} 超过来源上限 {ceiling}") + finding_type = str(item.get("finding_type") or "").strip() + if finding_type not in WEB_ALLOWED_FINDING_TYPES: + issues.append(f"第 {idx} 条网络搜索结果记录 finding_type 非法:{finding_type or '空'}") + if issues: + raise ValueError("网络搜索结果校验失败:" + ";".join(issues)) + + +def extract_external_findings(records: Sequence[Any]) -> List[ExternalFinding]: + findings: List[ExternalFinding] = [] + for item in records: + if not isinstance(item, dict): + continue + published_at = parse_iso_date(item.get("published_at")) + if published_at is None: + continue + findings.append( + ExternalFinding( + source_name=str(item.get("source_name") or "网络搜索来源").strip(), + title=clean_text(item.get("title")), + published_at=published_at, + summary=clean_text(item.get("summary")).rstrip("。;;!!??"), + why_relevant=clean_text(item.get("why_relevant")).rstrip("。;;!!??"), + confidence=int(float_or_none(item.get("confidence")) or 0), + finding_type=str(item.get("finding_type") or "").strip(), + ) + ) + findings.sort(key=lambda item: (item.published_at, item.confidence), reverse=True) + return findings + + +def build_company_lookup(*collections: List[Any]) -> Dict[str, str]: + lookup: Dict[str, str] = {} + for collection in collections: + for item in collection: + if isinstance(item, str): + lookup.setdefault(item, item) + continue + if not isinstance(item, dict): + continue + ticker = normalize_ticker(item) + if not ticker: + continue + lookup[ticker] = normalize_name(item) or lookup.get(ticker) or ticker + return lookup + + +def resolve_company_name(ticker: str, record: Dict[str, Any], lookup: Dict[str, str]) -> str: + return lookup.get(ticker) or normalize_name(record) or ticker or "未知公司" + + +def in_window(value: Optional[date], start_date: date, end_date: date) -> bool: + return value is not None and start_date <= value <= end_date + + +def choose_first_date(record: Dict[str, Any], fields: Sequence[str]) -> Optional[date]: + for field in fields: + parsed = parse_iso_date(record.get(field)) + if parsed: + return parsed + return None + + +def classify_announcement(title: str) -> Optional[str]: + low_signal_keywords = ( + "独立董事述职报告", + "投资者关系活动记录表", + "大宗交易", + "H股公告", + "证券变动月报表", + "可持续发展报告摘要", + "内部控制审计报告", + "内部控制评价报告", + "履职情况报告", + ) + if any(keyword in title for keyword in low_signal_keywords): + return None + + rules = [ + ("财报披露", ("业绩快报", "业绩预告", "年度报告", "半年度报告", "季报", "一季度报告", "三季度报告", "年报", "中报")), + ("分红回报", ("利润分配", "分红", "派息", "权益分派")), + ("治理事项", ("股东大会", "董事会", "监事会")), + ("资本运作", ("回购", "增持", "减持", "定增", "发行股份")), + ("投资者交流", ("业绩说明会", "说明会", "电话会", "路演")), + ("经营更新", ("签署", "中标", "合同", "进展", "合作", "项目", "产销快报", "销量", "月报", "经营数据")), + ] + for label, keywords in rules: + if any(keyword in title for keyword in keywords): + return label + return None + + +def format_amount_yi(value: Optional[float]) -> str: + if value is None: + return "无数据" + return f"{value / 1e8:.2f}亿元" + + +def format_pct(value: Optional[float], digits: int = 1) -> str: + if value is None: + return "无数据" + return f"{value:+.{digits}f}%" + + +def format_turnover(value: Optional[float]) -> str: + if value is None: + return "无数据" + return f"{value / 1e8:.2f}亿元" + + +def format_link_markdown(url: Optional[str], label: str = "原文") -> str: + if not url: + return "" + return f"[{label}]({url})" + + +def summarize_coverage(tickers: Sequence[str], lookup: Dict[str, str]) -> str: + if not tickers: + return "未识别覆盖股票池" + labels = [f"{lookup.get(ticker, ticker)}({ticker})" for ticker in tickers[:6]] + suffix = " 等" if len(tickers) > 6 else "" + return f"{len(tickers)}只股票:{'、'.join(labels)}{suffix}" + + +def load_raw_inputs(data_dir: Path) -> Tuple[Dict[str, List[Any]], List[str]]: + file_map = { + "stock_pool": "stock_pool.json", + "instrument_meta": "instrument_meta.json", + "latest_earnings": "latest_earnings.json", + "price_recent": "price_recent.json", + "hs300_recent": "hs300_recent.json", + "dividend_news": "dividend_news.json", + "announcement_raw": "announcement_raw.json", + } + loaded: Dict[str, List[Any]] = {} + missing: List[str] = [] + for key, filename in file_map.items(): + records = extract_records(read_json_file(data_dir / filename)) + loaded[key] = records + if not records: + missing.append(filename) + return loaded, missing + + +def collect_tickers(raw_inputs: Dict[str, List[Any]]) -> List[str]: + tickers: List[str] = [] + seen = set() + for key in ("stock_pool", "instrument_meta", "price_recent", "latest_earnings", "announcement_raw", "dividend_news"): + for item in raw_inputs.get(key, []): + ticker = "" + if isinstance(item, str): + ticker = item + elif isinstance(item, dict): + ticker = normalize_ticker(item) + if ticker and ticker not in seen: + seen.add(ticker) + tickers.append(ticker) + return tickers + + +def extract_recent_earnings( + records: List[Any], + lookup: Dict[str, str], + start_date: date, + end_date: date, +) -> List[EarningsItem]: + items: List[EarningsItem] = [] + seen = set() + for record in records: + if not isinstance(record, dict): + continue + ticker = normalize_ticker(record) + report_date = choose_first_date(record, ("report_date", "info_date", "announcement_date")) + if not ticker or not in_window(report_date, start_date, end_date): + continue + quarter = str(record.get("quarter") or record.get("report_period") or "最近一期") + unique_key = (ticker, quarter, report_date.isoformat()) + if unique_key in seen: + continue + seen.add(unique_key) + items.append( + EarningsItem( + ticker=ticker, + company=resolve_company_name(ticker, record, lookup), + quarter=quarter, + report_date=report_date, + revenue=float_or_none(record.get("revenue")), + net_profit=float_or_none(record.get("net_profit")), + ) + ) + items.sort(key=lambda item: (item.report_date, item.ticker), reverse=True) + return items + + +def extract_recent_announcements( + records: List[Any], + lookup: Dict[str, str], + start_date: date, + end_date: date, +) -> List[AnnouncementItem]: + items: List[AnnouncementItem] = [] + seen = set() + for record in records: + if not isinstance(record, dict): + continue + ticker = normalize_ticker(record) + title = normalize_title(record) + event_date = choose_first_date(record, ANNOUNCEMENT_DATE_FIELDS) + category = classify_announcement(title) + if not ticker or not title or not category or not in_window(event_date, start_date, end_date): + continue + unique_key = (ticker, title, event_date.isoformat()) + if unique_key in seen: + continue + seen.add(unique_key) + items.append( + AnnouncementItem( + ticker=ticker, + company=resolve_company_name(ticker, record, lookup), + title=title, + event_date=event_date, + category=category, + announcement_link=normalize_link(record.get("announcement_link")), + info_type=str(record.get("info_type") or "未分类"), + media=str(record.get("media") or "未知来源"), + ) + ) + items.sort(key=lambda item: (item.event_date, item.ticker), reverse=True) + return items + + +def build_as_of_time(report_date: date, raw_inputs: Dict[str, List[Any]]) -> str: + latest_dt: Optional[datetime] = None + for key in ("announcement_raw", "latest_earnings", "dividend_news", "price_recent", "hs300_recent", "web_search_findings"): + for item in raw_inputs.get(key, []): + if not isinstance(item, dict): + continue + for field in ("create_tm", "datetime", "info_date", "report_date", "announcement_date", "date"): + current = parse_iso_datetime(item.get(field)) + if current and (latest_dt is None or current > latest_dt): + latest_dt = current + if latest_dt: + if latest_dt.time() == datetime.min.time(): + return latest_dt.strftime("%Y-%m-%d") + return latest_dt.strftime("%Y-%m-%d %H:%M:%S") + return f"{report_date.isoformat()} 07:00:00" + + +def compute_avg_move(price_moves: Sequence[PriceMove]) -> Optional[float]: + if not price_moves: + return None + return sum(item.change_pct for item in price_moves) / len(price_moves) + + +def infer_opening_stance( + earnings_items: List[EarningsItem], + announcement_items: List[AnnouncementItem], + external_findings: List[ExternalFinding], + price_moves: List[PriceMove], + benchmark_move: Optional[float], +) -> Tuple[str, str, str]: + avg_move = compute_avg_move(price_moves) + avg_excess = avg_move - benchmark_move if avg_move is not None and benchmark_move is not None else avg_move + finance_count = len(earnings_items) + sum(1 for item in announcement_items if item.category == "财报披露") + core_count = sum(1 for item in announcement_items if item.category not in {"财报披露", "分红回报"}) + external_count = len(external_findings) + + if finance_count: + theme = f"今日晨会主线偏向财报/经营更新,隔夜共出现 {finance_count} 条财务披露,盘前需要优先确认是否触发预期修正。" + elif core_count: + theme = f"今日晨会主线偏向公告催化,隔夜共出现 {core_count} 条高相关度公告,重点判断事项是否足以驱动资金重新定价。" + elif external_count: + theme = f"今日晨会主线偏向网络搜索结果变化,隔夜补充到 {external_count} 条宏观/行业线索,盘前需要判断其是否会向覆盖股票池传导。" + else: + theme = "今日晨会缺少强事件催化,主线将更多依赖相对强弱和开盘后的量价确认。" + + if external_count and avg_excess is not None and avg_excess >= 0: + stance = "偏积极,网络搜索结果没有削弱风险偏好时,优先跟踪有基本面或公告配合的强势线索。" + position = "以结构性偏多为主,但只围绕真实催化和量价确认配置,不把网络搜索结果直接等同于交易结论。" + elif external_count and avg_excess is not None and avg_excess < 0: + stance = "中性偏谨慎,需先确认网络搜索结果能否对冲样本内的相对弱势。" + position = "控制追价节奏,优先等待网络搜索结果与个股公告、价格表现形成共振后再扩大风险暴露。" + elif avg_excess is not None and avg_excess >= 0.5: + stance = "偏积极,优先跟踪强势股的延续性,同时确认是否有基本面或公告继续配合。" + position = "结构性偏多,保留强势股跟踪仓位,但不宜在缺少新增催化时盲目追高。" + elif avg_excess is not None and avg_excess <= -0.5: + stance = "偏谨慎,盘前应先排查负面信息与预期落空,再决定是否参与弱势修复。" + position = "以防守和确认信息为主,弱势股需要看到负面出清或量价改善后再考虑加仓。" + else: + stance = "中性,优先依赖公告增量与开盘后的市场反馈来决定仓位方向。" + position = "维持中性仓位,围绕真实催化和相对强弱做结构性观察,不急于扩大风险暴露。" + return theme, stance, position + + +def announcement_check_point(item: AnnouncementItem) -> str: + if item.category == "财报披露": + return "盘前核查管理层表述、分红方案和全年经营指引是否超出市场预期。" + if item.category == "经营更新": + return "盘前核查销量、订单、项目或经营数据是否足以带来当期预期修正。" + if item.category == "资本运作": + return "盘前核查回购、增减持或融资事项对流通预期和情绪面的影响。" + if item.category == "治理事项": + return "盘前核查议案内容是否会引出新的治理、分红或资本运作催化。" + if item.category == "投资者交流": + return "盘前核查说明会主题、管理层出席安排以及是否可能释放新的经营口径。" + return "盘前核查公告是否会改变盈利预期、情绪定价或资金关注度。" + + +def describe_dividend_amount(record: Dict[str, Any]) -> str: + candidates = ( + ("cash_dividend_per_share", "每股派现"), + ("dividend_cash_before_tax", "税前现金分红"), + ("cash_dividend", "现金分红"), + ("dividend_per_share", "每股分红"), + ) + for field, label in candidates: + value = float_or_none(record.get(field)) + if value is not None: + return f"{label}{value:.4f}" + return "金额字段缺失" + + +def extract_recent_dividends( + records: List[Any], + lookup: Dict[str, str], + start_date: date, + end_date: date, +) -> List[DividendItem]: + items: List[DividendItem] = [] + seen = set() + tomorrow = end_date + timedelta(days=1) + for record in records: + if not isinstance(record, dict): + continue + ticker = normalize_ticker(record) + if not ticker: + continue + + announcement_date = choose_first_date(record, ("announcement_date", "ann_date", "date")) + ex_dividend_date = choose_first_date(record, ("ex_dividend_date", "book_closure_date")) + + event_date: Optional[date] = None + event_label = "" + if in_window(announcement_date, start_date, end_date): + event_date = announcement_date + event_label = "新披露分红信息" + elif ex_dividend_date and end_date <= ex_dividend_date <= tomorrow: + event_date = ex_dividend_date + event_label = "临近除权除息" + + if event_date is None: + continue + + unique_key = (ticker, event_label, event_date.isoformat()) + if unique_key in seen: + continue + seen.add(unique_key) + + items.append( + DividendItem( + ticker=ticker, + company=lookup.get(ticker, ticker), + event_label=event_label, + event_date=event_date, + amount_note=describe_dividend_amount(record), + ) + ) + items.sort(key=lambda item: (item.event_date, item.ticker), reverse=True) + return items + + +def extract_price_moves(records: List[Any], lookup: Dict[str, str]) -> List[PriceMove]: + grouped: Dict[str, List[Dict[str, Any]]] = defaultdict(list) + for record in records: + if not isinstance(record, dict): + continue + ticker = normalize_ticker(record) + if ticker: + grouped[ticker].append(record) + + items: List[PriceMove] = [] + for ticker, series in grouped.items(): + normalized: List[Tuple[date, float, Optional[float]]] = [] + for record in series: + event_date = choose_first_date(record, ("datetime", "date")) + close = float_or_none(record.get("close")) + if event_date is None or close is None: + continue + normalized.append((event_date, close, float_or_none(record.get("total_turnover")))) + normalized.sort(key=lambda item: item[0]) + deduped: Dict[date, Tuple[float, Optional[float]]] = {} + for event_date, close, turnover in normalized: + deduped[event_date] = (close, turnover) + ordered = sorted(deduped.items(), key=lambda item: item[0]) + if len(ordered) < 2: + continue + prev_close = ordered[-2][1][0] + last_close, turnover = ordered[-1][1] + change_pct = (last_close / prev_close - 1.0) * 100.0 if prev_close else 0.0 + items.append( + PriceMove( + ticker=ticker, + company=lookup.get(ticker, ticker), + last_close=last_close, + change_pct=change_pct, + turnover=turnover, + ) + ) + items.sort(key=lambda item: item.change_pct, reverse=True) + return items + + +def extract_benchmark_move(records: List[Any]) -> Optional[float]: + normalized: List[Tuple[date, float]] = [] + for record in records: + if not isinstance(record, dict): + continue + event_date = choose_first_date(record, ("datetime", "date")) + close = float_or_none(record.get("close")) + if event_date is None or close is None: + continue + normalized.append((event_date, close)) + normalized = sorted({event_date: close for event_date, close in normalized}.items(), key=lambda item: item[0]) + if len(normalized) < 2: + return None + prev_close = normalized[-2][1] + last_close = normalized[-1][1] + if not prev_close: + return None + return (last_close / prev_close - 1.0) * 100.0 + + +def build_exec_summary( + earnings_items: List[EarningsItem], + announcement_items: List[AnnouncementItem], + dividend_items: List[DividendItem], + external_findings: List[ExternalFinding], + price_moves: List[PriceMove], + benchmark_move: Optional[float], +) -> str: + finance_announcement_count = sum(1 for item in announcement_items if item.category == "财报披露") + core_announcement_count = sum(1 for item in announcement_items if item.category not in {"财报披露", "分红回报"}) + dividend_announcement_count = sum(1 for item in announcement_items if item.category == "分红回报") + theme, stance, position = infer_opening_stance( + earnings_items, + announcement_items, + external_findings, + price_moves, + benchmark_move, + ) + lines: List[str] = [] + lines.append(f"**核心观点**:{theme}") + lines.append( + f"隔夜窗口内共识别出 {len(earnings_items) + finance_announcement_count} 条财务披露、" + f"{core_announcement_count} 条重点公告、" + f"{len(dividend_items) + dividend_announcement_count} 条分红相关事项" + + (f"、{len(external_findings)} 条宏观/行业网络搜索结果。" if external_findings else "。") + ) + if price_moves: + avg_move = compute_avg_move(price_moves) + leader = price_moves[0] + laggard = min(price_moves, key=lambda item: item.change_pct) + benchmark_text = format_pct(benchmark_move) if benchmark_move is not None else "无基准数据" + lines.append( + f"覆盖股票池昨日平均涨跌幅为 {format_pct(avg_move)},沪深300 为 {benchmark_text};" + f"相对强势个股为 {leader.company}({leader.ticker}) {format_pct(leader.change_pct)}," + f"相对偏弱个股为 {laggard.company}({laggard.ticker}) {format_pct(laggard.change_pct)}。" + ) + else: + lines.append("价格数据不足,昨日市场回顾仅保留事件层面的重点提示。") + lines.append(f"**盘前定位**:{stance}") + lines.append(f"**持仓建议**:{position}") + lines.append("*数据来源:RQData,置信度5*") + if external_findings: + lines.append( + f"*补充网络搜索结果:{external_findings[0].source_name}" + + ( + f" 等 {len(external_findings)} 个来源," + f"置信度{min(item.confidence for item in external_findings)}-{max(item.confidence for item in external_findings)}*" + ) + ) + return "\n\n".join(lines) + + +def build_external_context_section(external_findings: List[ExternalFinding]) -> List[str]: + if not external_findings: + return [] + lines = ["### 宏观与行业语境"] + for item in external_findings[:5]: + lines.append( + f"- **{item.published_at.isoformat()} {item.source_name}**:{item.title}。{item.summary}。" + f" 对盘前判断的意义:{item.why_relevant}。 *数据来源:{item.source_name},置信度{item.confidence}*" + ) + return lines + + +def build_overnight_section( + earnings_items: List[EarningsItem], + announcement_items: List[AnnouncementItem], + dividend_items: List[DividendItem], + external_findings: List[ExternalFinding], +) -> str: + finance_announcements = [item for item in announcement_items if item.category == "财报披露"] + core_announcements = [item for item in announcement_items if item.category not in {"财报披露", "分红回报"}] + dividend_announcements = [item for item in announcement_items if item.category == "分红回报"] + lines: List[str] = [] + if external_findings: + lines.extend(build_external_context_section(external_findings)) + lines.append("") + if earnings_items: + lines.append("### 财务披露") + for item in earnings_items[:5]: + lines.append( + f"- **{item.company}({item.ticker})**:{item.report_date.isoformat()} 披露 {item.quarter}," + f"营收 {format_amount_yi(item.revenue)},净利润 {format_amount_yi(item.net_profit)};" + "盘前应优先确认利润兑现、现金流质量与管理层口径是否支持当前估值。" + ) + if finance_announcements: + for item in finance_announcements[:4]: + link_text = format_link_markdown(item.announcement_link) + lines.append( + f"- **{item.company}({item.ticker})**:{item.event_date.isoformat()} 披露 `{item.title}`," + f"属于公告口径下的财报/业绩更新,来源 {item.media};{announcement_check_point(item)}" + + (f" {link_text}" if link_text else "") + ) + lines.append("*数据来源:RQData,置信度5*") + elif finance_announcements: + lines.append("### 财务披露") + for item in finance_announcements[:6]: + link_text = format_link_markdown(item.announcement_link) + lines.append( + f"- **{item.company}({item.ticker})**:{item.event_date.isoformat()} 披露 `{item.title}`," + f"属于公告口径下的财报/业绩更新,来源 {item.media};{announcement_check_point(item)}" + + (f" {link_text}" if link_text else "") + ) + lines.append("*数据来源:RQData,置信度5*") + else: + lines.append("### 财务披露") + lines.append("- 隔夜窗口内未识别到覆盖股票池新的财报披露记录。") + lines.append("*数据来源:RQData,置信度5*") + + lines.append("") + if core_announcements: + lines.append("### 重点公告") + for item in core_announcements[:6]: + link_text = format_link_markdown(item.announcement_link) + lines.append( + f"- **{item.company}({item.ticker})**:{item.event_date.isoformat()} 披露 `{item.title}`,归类为{item.category}," + f"来源 {item.media};{announcement_check_point(item)}" + + (f" {link_text}" if link_text else "") + ) + lines.append("*数据来源:RQData,置信度5*") + else: + lines.append("### 重点公告") + lines.append("- 隔夜窗口内未识别到高相关度公告。") + lines.append("*数据来源:RQData,置信度5*") + + lines.append("") + if dividend_items or dividend_announcements: + lines.append("### 分红事项") + for item in dividend_items[:4]: + lines.append( + f"- **{item.company}({item.ticker})**:{item.event_label},日期 {item.event_date.isoformat()},{item.amount_note}。" + ) + for item in dividend_announcements[:4]: + link_text = format_link_markdown(item.announcement_link) + lines.append( + f"- **{item.company}({item.ticker})**:{item.event_date.isoformat()} 披露 `{item.title}`,属于分红/利润分配相关公告;" + "盘前需确认方案是否兑现为股息率改善或情绪催化。" + + (f" {link_text}" if link_text else "") + ) + lines.append("*数据来源:RQData,置信度5*") + else: + lines.append("### 分红事项") + lines.append("- 隔夜窗口内未识别到新增分红披露或临近除权除息事项。") + lines.append("*数据来源:RQData,置信度5*") + return "\n".join(lines) + + +def build_market_recap(price_moves: List[PriceMove], benchmark_move: Optional[float]) -> str: + if not price_moves: + return "价格数据不足,无法生成市场回顾表。\n\n*数据来源:RQData,置信度5*" + + up_count = sum(1 for item in price_moves if item.change_pct > 0) + down_count = sum(1 for item in price_moves if item.change_pct < 0) + flat_count = len(price_moves) - up_count - down_count + avg_move = sum(item.change_pct for item in price_moves) / len(price_moves) + benchmark_text = format_pct(benchmark_move) if benchmark_move is not None else "无基准数据" + + avg_turnover = [item.turnover for item in price_moves if item.turnover is not None] + avg_turnover_text = format_turnover(sum(avg_turnover) / len(avg_turnover)) if avg_turnover else "无数据" + + lines = [ + f"覆盖股票池昨日平均涨跌幅 {format_pct(avg_move)},上涨 {up_count} 家、下跌 {down_count} 家、平盘 {flat_count} 家;沪深300 为 {benchmark_text}," + f"样本单票平均成交额约为 {avg_turnover_text}。", + "", + "| 股票 | 收盘价 | 涨跌幅 | 成交额 |", + "| --- | ---: | ---: | ---: |", + ] + for item in price_moves: + lines.append( + f"| {item.company}({item.ticker}) | {item.last_close:.2f} | {format_pct(item.change_pct)} | {format_turnover(item.turnover)} |" + ) + leader = price_moves[0] + laggard = min(price_moves, key=lambda item: item.change_pct) + lines.append("") + lines.append( + f"相对强势的 {leader.company} 录得 {format_pct(leader.change_pct)}," + f"相对偏弱的 {laggard.company} 为 {format_pct(laggard.change_pct)}," + "两者将优先进入今日盘前观察名单。" + ) + if benchmark_move is not None: + breadth_bias = "偏强" if avg_move >= benchmark_move else "偏弱" + lines.append( + f"从广度看,当前覆盖池整体相对基准{breadth_bias};若开盘后强势股继续放量、弱势股未见新增利空,短线风格延续概率更高。" + ) + lines.append("") + lines.append("*数据来源:RQData,置信度5*") + return "\n".join(lines) + + +def build_watchlist( + earnings_items: List[EarningsItem], + announcement_items: List[AnnouncementItem], + dividend_items: List[DividendItem], + external_findings: List[ExternalFinding], + price_moves: List[PriceMove], + benchmark_move: Optional[float], +) -> str: + lines: List[str] = [] + used = set() + + for item in earnings_items[:2]: + key = ("earnings", item.ticker) + if key in used: + continue + used.add(key) + lines.append( + f"- **跟踪 {item.company}({item.ticker})**:隔夜披露 {item.quarter}," + f"重点确认营收 {format_amount_yi(item.revenue)} 与净利润 {format_amount_yi(item.net_profit)} 是否继续支撑股价表现," + "并结合管理层表述判断预期差方向。" + ) + + for item in announcement_items[:2]: + key = ("announcement", item.ticker, item.title) + if key in used: + continue + used.add(key) + lines.append( + f"- **关注 {item.company}({item.ticker})**:最新公告为 `{item.title}`," + f"属于{item.category},盘前需要判断该事项是否会带来预期修正;{announcement_check_point(item)}" + ) + + for item in dividend_items[:1]: + key = ("dividend", item.ticker) + if key in used: + continue + used.add(key) + lines.append( + f"- **留意 {item.company}({item.ticker})**:{item.event_label},日期为 {item.event_date.isoformat()}," + f"分红信息为 `{item.amount_note}`,需确认其对股息率和情绪面的边际影响。" + ) + + for item in external_findings[:2]: + lines.append( + f"- **网络搜索结果跟踪**:{item.source_name} 最新提到“{item.title}”," + f"盘前需要确认这条线索是否会传导到覆盖池中的相关行业或主题。" + ) + + if price_moves: + leader = price_moves[0] + laggard = min(price_moves, key=lambda item: item.change_pct) + excess = leader.change_pct - benchmark_move if benchmark_move is not None else leader.change_pct + lines.append( + f"- **相对强势观察**:{leader.company}({leader.ticker}) 昨日涨跌幅 {format_pct(leader.change_pct)}," + f"相对沪深300 的超额收益约为 {format_pct(excess)},盘前重点观察是否存在公告或基本面配合。" + ) + lines.append( + f"- **相对偏弱观察**:{laggard.company}({laggard.ticker}) 昨日涨跌幅 {format_pct(laggard.change_pct)}," + "若开盘仍弱于板块,需要确认是否存在新增负面信息或前一日交易拥挤导致的补跌。" + ) + + if not lines: + lines.append("- 无新增高优先级事件,今日重点以价格相对强弱和公告增量信息为主。") + + lines.append("") + lines.append("*数据来源:RQData,置信度5*") + if external_findings: + lines.append( + f"*补充网络搜索结果:{external_findings[0].source_name}" + + ( + f" 等 {len(external_findings)} 个来源," + f"置信度{min(item.confidence for item in external_findings)}-{max(item.confidence for item in external_findings)}*" + ) + ) + return "\n".join(lines) + + +def build_trade_observations( + earnings_items: List[EarningsItem], + announcement_items: List[AnnouncementItem], + price_moves: List[PriceMove], + benchmark_move: Optional[float], +) -> str: + lines: List[str] = [] + if price_moves: + leader = price_moves[0] + excess = leader.change_pct - benchmark_move if benchmark_move is not None else leader.change_pct + lines.append( + f"- **强势延续观察:{leader.company}({leader.ticker})**。昨日录得 {format_pct(leader.change_pct)}," + f"相对基准超额收益约 {format_pct(excess)};若盘前没有新的负面公告,开盘后的量价延续值得跟踪。" + ) + lines.append( + "- 强势延续失效条件:若开盘后迅速跌回前一日收盘下方且成交并未放大,说明强势更多来自短线波动而非新增信息。" + ) + laggard = min(price_moves, key=lambda item: item.change_pct) + lines.append( + f"- **弱势修复观察:{laggard.company}({laggard.ticker})**。昨日表现为 {format_pct(laggard.change_pct)}," + "若隔夜无新增利空且低开后快速收窄跌幅,可能形成短线修复观察点。" + ) + lines.append( + "- 弱势修复失效条件:若弱势继续放量扩大且跑输板块,说明负面预期仍在发酵,应避免把技术性反弹误判为修复。" + ) + if earnings_items: + item = earnings_items[0] + lines.append( + f"- **事件跟踪观察:{item.company}({item.ticker})**。最新披露 {item.quarter}," + f"营收 {format_amount_yi(item.revenue)}、净利润 {format_amount_yi(item.net_profit)}," + "盘前需结合市场预期判断情绪发酵方向。" + ) + elif announcement_items: + item = announcement_items[0] + link_text = format_link_markdown(item.announcement_link) + lines.append( + f"- **公告催化观察:{item.company}({item.ticker})**。最新事项为 `{item.title}`," + f"属于{item.category},需判断其是否足以驱动开盘后的资金聚焦。" + + (f" {link_text}" if link_text else "") + ) + + if not lines: + lines.append("- 当前数据不足以支持高置信度交易观察,建议优先等待新增公告或开盘后量价确认。") + + lines.append("") + lines.append("*数据来源:RQData,置信度5*") + return "\n".join(lines) + + +def build_risk_alerts( + announcement_items: List[AnnouncementItem], + external_findings: List[ExternalFinding], + price_moves: List[PriceMove], + benchmark_move: Optional[float], + missing_files: List[str], +) -> str: + lines: List[str] = [] + if "announcement_raw.json" in missing_files: + lines.append("- **公告覆盖风险**:当前隔夜公告样本不足,盘前结论可能遗漏正式披露的新增信息。") + if price_moves: + avg_move = compute_avg_move(price_moves) + if benchmark_move is not None and avg_move is not None and avg_move < benchmark_move - 0.5: + lines.append("- **情绪偏弱风险**:覆盖股票池昨日整体明显跑输沪深300,开盘后弱势股可能继续承压。") + spread = price_moves[0].change_pct - min(price_moves, key=lambda item: item.change_pct).change_pct + if spread >= 3.0: + lines.append(f"- **分化加剧风险**:样本内强弱股日收益差约为 {format_pct(spread)},盘前不宜把个股走势简单外推为板块共振。") + if announcement_items: + finance_count = sum(1 for item in announcement_items if item.category == "财报披露") + if finance_count: + lines.append("- **信息超预期风险**:财报类公告可能包含管理层对经营、分红和风险的新增表述,盘前需要先核查原文再下结论。") + if external_findings: + low_conf = [item for item in external_findings if item.confidence <= 3] + if low_conf: + lines.append("- **网络搜索结果确认风险**:部分宏观/行业线索来自非一级来源,盘前只能作为关注线索,不能直接替代交易判断。") + if not lines: + lines.append("- 当前未识别到突出的新增风险,但仍需关注盘前公告增量、开盘量价和样本内强弱分化。") + lines.append("") + lines.append("*数据来源:RQData,置信度5*") + return "\n".join(lines) + + +def build_appendix( + report_date: date, + lookback_start: date, + loaded_counts: Dict[str, int], + missing_files: List[str], +) -> str: + display_names = { + "stock_pool": "覆盖股票池", + "instrument_meta": "公司元数据", + "latest_earnings": "财务披露样本", + "price_recent": "个股价格样本", + "hs300_recent": "基准指数样本", + "dividend_news": "分红事项样本", + "announcement_raw": "隔夜公告样本", + "web_search_findings": "网络搜索结果样本", + } + missing_labels = [display_names.get(item.replace('.json', ''), item) for item in missing_files] + lines = [ + f"- 报告日期为 {report_date.isoformat()},隔夜观察起始日为 {lookback_start.isoformat()}。", + "- 事件优先级顺序:财报披露 -> 重点公告 -> 分红事项 -> 相对强弱。", + "- 若高优先级公告存在原文链接,正文会保留追溯入口,便于后续核查正式披露内容。", + "- 若输入数据缺失,报告会明确标记无数据或未验证,不会伪造内容。", + "", + "### 输入文件加载情况", + ] + for label, count in loaded_counts.items(): + lines.append(f"- {label}:{count} 条记录") + if missing_labels: + lines.append(f"- 当前样本不足的模块:{'、'.join(missing_labels)}") + else: + lines.append("- 所有约定文件均已加载") + return "\n".join(lines) + + +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"警告:仓库内 report-renderer 渲染失败:{exc}") + + print("警告:未找到可用的 HTML 渲染器,保留 Markdown 输出") + return None + + +def main() -> None: + args = parse_args() + data_dir = Path(args.data_dir).expanduser() + report_date = date.fromisoformat(args.report_date) + lookback_start = date.fromisoformat(args.lookback_start) if args.lookback_start else report_date - timedelta(days=1) + + raw_inputs, missing_files = load_raw_inputs(data_dir) + lookup = build_company_lookup(raw_inputs["stock_pool"], raw_inputs["instrument_meta"]) + tickers = collect_tickers(raw_inputs) + web_search_records = extract_records(read_json_file(data_dir / "web_search_findings.json")) + validate_web_search_records(web_search_records) + raw_inputs["web_search_findings"] = web_search_records + + earnings_items = extract_recent_earnings(raw_inputs["latest_earnings"], lookup, lookback_start, report_date) + announcement_items = extract_recent_announcements(raw_inputs["announcement_raw"], lookup, lookback_start, report_date) + dividend_items = extract_recent_dividends(raw_inputs["dividend_news"], lookup, lookback_start, report_date) + external_findings = extract_external_findings(web_search_records) + price_moves = extract_price_moves(raw_inputs["price_recent"], lookup) + benchmark_move = extract_benchmark_move(raw_inputs["hs300_recent"]) + + coverage_scope = summarize_coverage(tickers, lookup) + loaded_counts = { + "覆盖股票池": len(raw_inputs["stock_pool"]), + "公司元数据": len(raw_inputs["instrument_meta"]), + "财务披露样本": len(raw_inputs["latest_earnings"]), + "个股价格样本": len(raw_inputs["price_recent"]), + "基准指数样本": len(raw_inputs["hs300_recent"]), + "分红事项样本": len(raw_inputs["dividend_news"]), + "隔夜公告样本": len(raw_inputs["announcement_raw"]), + "网络搜索结果样本": len(web_search_records), + } + + template_path = Path(args.template).expanduser() + template_text = template_path.read_text(encoding="utf-8") + report_text = render_template( + template_text, + { + "REPORT_DATE": report_date.isoformat(), + "AS_OF_TIME": build_as_of_time(report_date, raw_inputs), + "LOOKBACK_START": lookback_start.isoformat(), + "COVERAGE_SCOPE": coverage_scope, + "EXEC_SUMMARY": build_exec_summary( + earnings_items, + announcement_items, + dividend_items, + external_findings, + price_moves, + benchmark_move, + ), + "OVERNIGHT_DEVELOPMENTS": build_overnight_section( + earnings_items, + announcement_items, + dividend_items, + external_findings, + ), + "MARKET_RECAP": build_market_recap(price_moves, benchmark_move), + "WATCHLIST": build_watchlist( + earnings_items, + announcement_items, + dividend_items, + external_findings, + price_moves, + benchmark_move, + ), + "TRADE_OBSERVATIONS": build_trade_observations( + earnings_items, + announcement_items, + price_moves, + benchmark_move, + ), + "RISK_ALERTS": build_risk_alerts( + announcement_items, + external_findings, + price_moves, + benchmark_move, + missing_files, + ), + "APPENDIX": build_appendix(report_date, lookback_start, loaded_counts, missing_files), + }, + ) + + if args.output: + output_path = Path(args.output).expanduser() + else: + output_path = data_dir / f"morning_note_{report_date.isoformat()}.md" + 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() diff --git a/.cursor/skills/quantdinger-ricequant-data/SKILL.md b/.cursor/skills/quantdinger-ricequant-data/SKILL.md new file mode 100644 index 000000000..9fe0eda42 --- /dev/null +++ b/.cursor/skills/quantdinger-ricequant-data/SKILL.md @@ -0,0 +1,38 @@ +--- +name: quantdinger-ricequant-data +description: >- + Routes QuantDinger CN stocks, HK stocks, and Chinese futures market data + through Ricequant RQData (rqdatac.get_price). Use when editing kline/ticker + adapters, DATE archives, CN_STOCK_PROVIDER / HK_STOCK_PROVIDER / + FUTURES_CN_PROVIDER, or Ricequant skill wiring. +--- + +# QuantDinger Ricequant data source + +On branch `feat/ricequant-data-source`, research bars for **CNStock**, **HKStock**, and **CN futures** come from Ricequant RQData first. + +## Defaults + +- `CN_STOCK_PROVIDER=rqdata` +- `HK_STOCK_PROVIDER=rqdata` +- `FUTURES_CN_PROVIDER=rqdata` +- Credentials: `RQDATAC_LICENSE` / `RQDATAC_URI` / `RQDATAC_USERNAME`+`RQDATAC_PASSWORD` +- Licensed client: `pip install rqdatac` (not in the default requirements lock) + +## Symbol mapping + +- A-share: `600519` → `600519.XSHG`, `000001` → `000001.XSHE` +- Index: `000001.SH` → `000001.XSHG`, `399006.SZ` → `399006.XSHE` (`adjust_type=none`) +- HK: `0700.HK` → `00700.XHKG` +- CN futures: existing `to_rqdata_order_book_id` (`RB0` → `RB88`) + +## Code + +- Shared init: `backend_api_python/app/data_sources/rqdata_futures.py` +- Equities: `backend_api_python/app/data_sources/rqdata_equity.py` +- Callers: `cn_stock.py`, `hk_stock.py`, `futures.py` +- Local CSV archive still writes through `DataSourceFactory` into `DATE/` + +## Agent skills + +Installed from https://github.com/Jason55-1118/ricequant-skills into `.cursor/skills/` (`ricequant`, `rqdata-python`, `rqams`, research skills). For RQData API lookup, follow `.cursor/skills/rqdata-python/SKILL.md`. diff --git a/.cursor/skills/report-renderer/SKILL.md b/.cursor/skills/report-renderer/SKILL.md new file mode 100644 index 000000000..35aa122fd --- /dev/null +++ b/.cursor/skills/report-renderer/SKILL.md @@ -0,0 +1,112 @@ +--- +name: rq-report-renderer +description: | + 将 Markdown 研究报告渲染为专业 HTML 文档。输入是其他 skill 已生成的 Markdown 报告,输出是可浏览、可打印的单文件 HTML。 + + 务必使用此技能当用户: + - 明确要求把 Markdown 报告转换成 HTML + - 想生成网页版研报或打印版页面 + - 已经有 `.md` 报告,只差最后的渲染步骤 + + 不适用场景: + - 还没有 Markdown 报告 + - 需要 PDF / Word 导出 +compatibility: "Requires python3. Does not rely on repo-level utils or external Markdown packages." +--- + +# RQ 股票研究 - HTML 报告渲染器 + +## 核心原则 + +- 输入必须是显式的 Markdown 文件路径,不能依赖固定目录 +- skill 必须自包含,渲染逻辑、样式和输入契约都放在 `report-renderer/` 内 +- 不依赖仓库级 `utils` +- 不要求外部 Markdown 库;脚本内置最小可用解析器,覆盖当前各 skill 的真实输出结构 +- 输出必须保留标题、章节、表格、列表、代码样式与数据来源标注 +- 若本机支持打开浏览器,应作为可选行为,而不是强制副作用 + +## 目标产出 + +- 单文件 HTML 报告 +- 默认与输入 Markdown 同目录同名输出 +- 适合桌面阅读与打印 + +## 目录结构 + +```text +report-renderer/ +├── SKILL.md +├── scripts/ +│ └── render_report.py +├── assets/ +│ └── report.css +└── references/ + └── input_contract.md +``` + +## 输入契约 + +- 主输入:一个 Markdown 文件 +- 可选参数: + - 输出 HTML 路径 + - 覆盖标题 + - `--open`:渲染后尝试在默认浏览器打开 + +完整输入格式与支持的 Markdown 子集见 [references/input_contract.md](references/input_contract.md)。 + +## 工作流 + +### 步骤 1:准备输入文件 + +确认已有 Markdown 报告,例如: + +```bash +INPUT_MD="skill-test-results/sector-overview/baijiu/output/sector_overview_baijiu_2026-04-03.md" +OUTPUT_HTML="${INPUT_MD%.md}.html" +``` + +### 步骤 2:执行渲染 + +主入口: + +```bash +python3 report-renderer/scripts/render_report.py "$INPUT_MD" "$OUTPUT_HTML" +``` + +若希望渲染后尝试打开浏览器: + +```bash +python3 report-renderer/scripts/render_report.py "$INPUT_MD" "$OUTPUT_HTML" --open +``` + +### 步骤 3:验证输出 + +- HTML 文件存在 +- HTML 中包含 `` +- 主标题、主要章节和表格都已渲染 +- 不应残留原始 Markdown 表格分隔行(如 `| --- | --- |`) +- `数据来源:RQData,置信度5` 等来源标注仍保留 + +## 参数说明 + +- 第一个位置参数:输入 Markdown 文件 +- 第二个位置参数:输出 HTML 文件,可省略 +- `--title`:覆盖封面标题 +- `--open`:渲染完成后尝试打开浏览器 + +## 报告质量要求 + +- 输出必须是完整 HTML,而不是片段 +- 章节层级必须可导航,至少生成二级目录 +- 表格必须渲染成 `` +- 列表必须渲染成 `
    ` / `
      ` +- 粗体、斜体、行内代码与 `
      ` 必须保留 +- 样式必须独立内联或由 skill 自带资源注入,不能依赖外部 CDN + +## 常见错误 + +- 把完整渲染脚本直接塞回 `SKILL.md`,没有落地成可执行文件 +- 只渲染段落,不支持 Markdown 表格 +- 把列表项直接变成裸 `
    1. `,没有外层 `
        ` / `
          ` +- 强依赖外部 Markdown 库,导致在最小环境里不可执行 +- 依赖 repo 里的 `utils/html_renderer.py` diff --git a/.cursor/skills/report-renderer/assets/report.css b/.cursor/skills/report-renderer/assets/report.css new file mode 100644 index 000000000..8eef6f618 --- /dev/null +++ b/.cursor/skills/report-renderer/assets/report.css @@ -0,0 +1,378 @@ +:root { + --paper: #fbf8f1; + --surface: #fffdf8; + --ink: #1d2430; + --muted: #5e6875; + --line: #d7d4ca; + --accent: #a6482f; + --accent-deep: #6b2f21; + --accent-soft: #efe2d7; + --table-head: #293241; + --code-bg: #f3efe8; + --quote-bg: #f5ede4; + --shadow: 0 24px 80px rgba(24, 31, 42, 0.12); +} + +* { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; +} + +body { + margin: 0; + background: + radial-gradient(circle at top left, rgba(166, 72, 47, 0.10), transparent 32%), + linear-gradient(180deg, #f3ecdf 0%, var(--paper) 18%, #f7f4ee 100%); + color: var(--ink); + font-family: "Noto Serif SC", "Source Han Serif SC", "Songti SC", "STSong", serif; + line-height: 1.75; +} + +a { + color: inherit; +} + +.page { + width: min(1200px, calc(100vw - 32px)); + margin: 24px auto 64px; + display: grid; + grid-template-columns: 280px minmax(0, 1fr); + gap: 24px; +} + +.sidebar { + position: sticky; + top: 24px; + align-self: start; + background: rgba(255, 253, 248, 0.94); + border: 1px solid rgba(107, 47, 33, 0.10); + border-radius: 20px; + padding: 24px 22px; + box-shadow: var(--shadow); + backdrop-filter: blur(16px); +} + +.eyebrow { + font-family: "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif; + font-size: 12px; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--accent); + margin-bottom: 10px; +} + +.sidebar-title { + margin: 0; + font-size: 28px; + line-height: 1.25; +} + +.meta-list { + list-style: none; + margin: 18px 0 0; + padding: 0; + font-family: "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif; + font-size: 13px; + color: var(--muted); +} + +.meta-list li + li { + margin-top: 8px; +} + +.toc { + margin-top: 24px; + border-top: 1px solid var(--line); + padding-top: 20px; +} + +.toc-title { + margin: 0 0 10px; + font-family: "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif; + font-size: 12px; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--muted); +} + +.toc ul { + list-style: none; + margin: 0; + padding: 0; +} + +.toc li + li { + margin-top: 8px; +} + +.toc a { + display: block; + text-decoration: none; + color: var(--ink); + padding: 6px 8px; + border-radius: 8px; + transition: background-color 120ms ease, transform 120ms ease; +} + +.toc a:hover { + background: var(--accent-soft); + transform: translateX(2px); +} + +.toc .level-3 a, +.toc .level-4 a { + padding-left: 18px; + color: var(--muted); +} + +.report { + background: var(--surface); + border: 1px solid rgba(41, 50, 65, 0.08); + border-radius: 28px; + overflow: hidden; + box-shadow: var(--shadow); +} + +.cover { + padding: 56px 56px 44px; + background: + linear-gradient(135deg, rgba(166, 72, 47, 0.96), rgba(76, 33, 24, 0.95)), + linear-gradient(180deg, #784032 0%, #3d1e17 100%); + color: #fffaf3; +} + +.cover-kicker { + margin: 0 0 10px; + font-family: "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif; + letter-spacing: 0.16em; + text-transform: uppercase; + font-size: 12px; + opacity: 0.8; +} + +.cover-title { + margin: 0; + font-size: clamp(32px, 4vw, 48px); + line-height: 1.18; +} + +.cover-subtitle { + margin: 16px 0 0; + font-size: 17px; + line-height: 1.6; + max-width: 56ch; + opacity: 0.92; +} + +.cover-meta { + margin-top: 24px; + display: flex; + flex-wrap: wrap; + gap: 12px; + font-family: "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif; + font-size: 13px; +} + +.chip { + border: 1px solid rgba(255, 250, 243, 0.24); + border-radius: 999px; + padding: 6px 12px; + background: rgba(255, 250, 243, 0.08); +} + +.content { + padding: 40px 56px 56px; +} + +.content h1, +.content h2, +.content h3, +.content h4 { + scroll-margin-top: 24px; +} + +.content h1 { + margin: 32px 0 16px; + font-size: 30px; + line-height: 1.28; + color: var(--accent-deep); +} + +.content h2 { + margin: 36px 0 14px; + padding-bottom: 8px; + border-bottom: 1px solid rgba(166, 72, 47, 0.22); + font-size: 24px; + line-height: 1.3; + color: var(--accent-deep); +} + +.content h3 { + margin: 26px 0 10px; + font-size: 19px; + line-height: 1.35; +} + +.content h4 { + margin: 18px 0 8px; + font-size: 16px; + line-height: 1.4; +} + +.content p { + margin: 12px 0; + font-size: 15px; +} + +.content ul, +.content ol { + margin: 12px 0 16px; + padding-left: 24px; +} + +.content li + li { + margin-top: 6px; +} + +.content hr { + border: 0; + border-top: 1px solid var(--line); + margin: 28px 0; +} + +.content blockquote { + margin: 18px 0; + padding: 14px 18px; + border-left: 4px solid var(--accent); + background: var(--quote-bg); + color: var(--muted); +} + +.content code { + background: var(--code-bg); + border-radius: 6px; + padding: 2px 6px; + font-size: 0.92em; + font-family: "SFMono-Regular", "Menlo", "Monaco", "Courier New", monospace; +} + +.code-block { + overflow-x: auto; + background: #211f1c; + color: #f6efe7; + padding: 16px 18px; + border-radius: 16px; + font-size: 13px; + line-height: 1.65; +} + +.content table { + width: 100%; + border-collapse: collapse; + margin: 18px 0 12px; + table-layout: fixed; + font-size: 14px; +} + +.content thead th { + background: var(--table-head); + color: #f8fafc; + padding: 10px 12px; + text-align: left; + border: 1px solid rgba(255, 255, 255, 0.08); +} + +.content tbody td { + padding: 10px 12px; + border: 1px solid #ddd7cb; + vertical-align: top; +} + +.content tbody tr:nth-child(even) td { + background: #fcf8f1; +} + +.align-left { + text-align: left; +} + +.align-center { + text-align: center; +} + +.align-right { + text-align: right; +} + +.source-note { + color: var(--muted); + font-style: italic; + font-size: 13px; +} + +.footer { + margin-top: 40px; + padding-top: 18px; + border-top: 1px solid var(--line); + color: var(--muted); + font-family: "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif; + font-size: 12px; +} + +@media (max-width: 980px) { + .page { + grid-template-columns: 1fr; + } + + .sidebar { + position: static; + } +} + +@media (max-width: 640px) { + .page { + width: calc(100vw - 18px); + margin: 10px auto 32px; + gap: 12px; + } + + .cover, + .content { + padding-left: 20px; + padding-right: 20px; + } + + .content table { + display: block; + overflow-x: auto; + white-space: nowrap; + } +} + +@media print { + body { + background: #fff; + } + + .page { + width: auto; + margin: 0; + display: block; + } + + .sidebar { + display: none; + } + + .report { + box-shadow: none; + border: 0; + } + + .cover { + page-break-after: always; + } +} diff --git a/.cursor/skills/report-renderer/references/input_contract.md b/.cursor/skills/report-renderer/references/input_contract.md new file mode 100644 index 000000000..45e61b3f1 --- /dev/null +++ b/.cursor/skills/report-renderer/references/input_contract.md @@ -0,0 +1,58 @@ +# report-renderer 输入契约 + +`report-renderer/scripts/render_report.py` 接收一个 Markdown 文件并输出单文件 HTML。 + +## 1. 输入文件 + +推荐输入是由其他 research skill 生成的 Markdown 报告,例如: + +- `morning-note` +- `thesis-tracker` +- `idea-generation` +- `earnings-preview` +- `sector-overview` + +## 2. 支持的 Markdown 子集 + +渲染器内置解析器,当前保证覆盖: + +- ATX 标题:`#` / `##` / `###` / `####` +- 无序列表:`- item` / `* item` +- 有序列表:`1. item` +- Markdown 表格 +- 普通段落 +- 分隔线:`---` +- 块引用:`> quote` +- 代码块:````` ```lang ... ``` ````` +- 行内粗体:`**text**` +- 行内斜体:`*text*` +- 行内代码:`` `code` `` +- 表格单元格中的原生 `
          ` + +## 3. 元数据抽取 + +渲染器会尝试从 Markdown 前几行提取: + +- 主标题 +- 报告日期 +- 行业 / 公司 / 覆盖范围等摘要字段 + +若未识别到元数据: + +- 标题退回到文件名 +- 报告日期显示为 `未提供` + +## 4. 输出约定 + +- 默认输出路径为输入文件同名 `.html` +- 输出是完整 HTML5 文档 +- CSS 由 `assets/report.css` 内联注入,不依赖外部样式资源 + +## 5. 非目标能力 + +当前不保证完整支持: + +- 嵌套列表 +- 复杂表格合并单元格 +- 任意 HTML 嵌入 +- 脚注、任务列表、LaTeX diff --git a/.cursor/skills/report-renderer/scripts/render_report.py b/.cursor/skills/report-renderer/scripts/render_report.py new file mode 100644 index 000000000..991dc4ada --- /dev/null +++ b/.cursor/skills/report-renderer/scripts/render_report.py @@ -0,0 +1,439 @@ +#!/usr/bin/env python3 +"""Render Markdown research reports into standalone HTML.""" + +from __future__ import annotations + +import argparse +import html +import re +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable, List, Optional, Sequence + + +HEADING_RE = re.compile(r"^(#{1,4})\s+(.*\S)\s*$") +ORDERED_ITEM_RE = re.compile(r"^\d+\.\s+(.*\S)\s*$") +UNORDERED_ITEM_RE = re.compile(r"^[-*]\s+(.*\S)\s*$") +BLOCKQUOTE_RE = re.compile(r"^>\s?(.*)$") +REPORT_DATE_RE = re.compile(r"报告日期[::]\s*([^\n]+)") +META_LINE_RE = re.compile(r"^[-*]\s*([^::]{1,24})[::]\s*(.+)$") + + +@dataclass +class Heading: + level: int + text: str + anchor: str + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="将 Markdown 研究报告渲染为单文件 HTML") + parser.add_argument("input", help="输入 Markdown 文件路径") + parser.add_argument("output", nargs="?", help="输出 HTML 文件路径,默认与输入同名") + parser.add_argument("--title", help="覆盖封面标题") + parser.add_argument("--open", action="store_true", help="渲染完成后尝试在默认浏览器打开") + return parser.parse_args() + + +def load_css(script_path: Path) -> str: + css_path = script_path.resolve().parent.parent / "assets" / "report.css" + return css_path.read_text(encoding="utf-8") + + +def split_table_row(line: str) -> List[str]: + inner = line.strip().strip("|") + return [cell.strip() for cell in inner.split("|")] + + +def is_table_divider(cells: Sequence[str]) -> bool: + if not cells: + return False + for cell in cells: + stripped = cell.replace(":", "").replace("-", "").strip() + if stripped: + return False + return True + + +def cell_alignment(cell: str) -> str: + stripped = cell.strip() + if stripped.startswith(":") and stripped.endswith(":"): + return "align-center" + if stripped.endswith(":"): + return "align-right" + return "align-left" + + +def preserve_soft_breaks(text: str) -> str: + return text.replace("<br>", "
          ") + + +def render_inline(text: str) -> str: + escaped = preserve_soft_breaks(html.escape(text, quote=False)) + + def replace_code(match: re.Match[str]) -> str: + return f"{match.group(1)}" + + def replace_link(match: re.Match[str]) -> str: + label = match.group(1) + url = html.escape(match.group(2), quote=True) + return f'{label}' + + escaped = re.sub(r"`([^`]+)`", replace_code, escaped) + escaped = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", replace_link, escaped) + escaped = re.sub(r"\*\*([^*]+)\*\*", r"\1", escaped) + escaped = re.sub(r"(?\1", escaped) + return escaped + + +def slugify(text: str, index: int) -> str: + compact = re.sub(r"\s+", "-", text.strip().lower()) + compact = re.sub(r"[^0-9a-zA-Z\u4e00-\u9fff-]", "", compact) + compact = compact.strip("-") + return compact or f"section-{index}" + + +def parse_metadata(markdown_text: str, markdown_path: Path) -> dict[str, str]: + lines = markdown_text.splitlines() + title = markdown_path.stem + for line in lines: + match = HEADING_RE.match(line) + if match and len(match.group(1)) == 1: + title = match.group(2).strip() + break + + report_date_match = REPORT_DATE_RE.search(markdown_text) + report_date = report_date_match.group(1).strip() if report_date_match else "未提供" + + meta: dict[str, str] = {"title": title, "report_date": report_date} + for line in lines[:20]: + meta_match = META_LINE_RE.match(line.strip()) + if meta_match: + key = meta_match.group(1).strip() + value = meta_match.group(2).strip() + if key and value: + meta[key] = value + return meta + + +def render_table(table_lines: Sequence[str]) -> str: + rows = [split_table_row(line) for line in table_lines if line.strip()] + if not rows: + return "" + + header = rows[0] + divider = rows[1] if len(rows) > 1 and is_table_divider(rows[1]) else None + alignments = [cell_alignment(cell) for cell in divider] if divider else ["align-left"] * len(header) + body_rows = rows[2:] if divider else rows[1:] + + head_html = "".join(f'
' for idx, cell in enumerate(header)) + parts = ["
{render_inline(cell)}
", f"{head_html}", ""] + for row in body_rows: + cells = [] + for idx, cell in enumerate(row): + align = alignments[idx] if idx < len(alignments) else "align-left" + cells.append(f'') + parts.append("" + "".join(cells) + "") + parts.append("
{render_inline(cell)}
") + return "".join(parts) + + +def render_list(list_lines: Sequence[str], ordered: bool) -> str: + tag = "ol" if ordered else "ul" + items = [] + for line in list_lines: + match = ORDERED_ITEM_RE.match(line) if ordered else UNORDERED_ITEM_RE.match(line) + if match: + items.append(f"
  • {render_inline(match.group(1).strip())}
  • ") + return f"<{tag}>" + "".join(items) + f"" + + +def render_code_block(code_lines: Sequence[str], language: str) -> str: + payload = html.escape("\n".join(code_lines), quote=False) + class_attr = f' data-language="{html.escape(language, quote=True)}"' if language else "" + return f'
    {payload}
    ' + + +def paragraph_class(text: str) -> str: + plain = re.sub(r"<[^>]+>", "", text).strip() + if plain.startswith("数据来源:") or plain.startswith("*数据来源:"): + return " class=\"source-note\"" + return "" + + +def render_markdown(markdown_text: str) -> tuple[str, List[Heading]]: + lines = markdown_text.splitlines() + parts: List[str] = [] + headings: List[Heading] = [] + paragraph: List[str] = [] + unordered_items: List[str] = [] + ordered_items: List[str] = [] + table_lines: List[str] = [] + quote_lines: List[str] = [] + code_lines: List[str] = [] + in_code_block = False + code_language = "" + heading_index = 0 + + def flush_paragraph() -> None: + nonlocal paragraph + if not paragraph: + return + text = render_inline(" ".join(item.strip() for item in paragraph)) + parts.append(f"{text}

    ") + paragraph = [] + + def flush_unordered() -> None: + nonlocal unordered_items + if unordered_items: + parts.append(render_list(unordered_items, ordered=False)) + unordered_items = [] + + def flush_ordered() -> None: + nonlocal ordered_items + if ordered_items: + parts.append(render_list(ordered_items, ordered=True)) + ordered_items = [] + + def flush_table() -> None: + nonlocal table_lines + if table_lines: + parts.append(render_table(table_lines)) + table_lines = [] + + def flush_quote() -> None: + nonlocal quote_lines + if quote_lines: + text = render_inline(" ".join(quote_lines)) + parts.append(f"

    {text}

    ") + quote_lines = [] + + for raw_line in lines: + line = raw_line.rstrip("\n") + + if in_code_block: + if line.strip().startswith("```"): + parts.append(render_code_block(code_lines, code_language)) + code_lines = [] + in_code_block = False + code_language = "" + else: + code_lines.append(line) + continue + + if line.strip().startswith("```"): + flush_paragraph() + flush_unordered() + flush_ordered() + flush_table() + flush_quote() + in_code_block = True + code_language = line.strip()[3:].strip() + code_lines = [] + continue + + if not line.strip(): + flush_paragraph() + flush_unordered() + flush_ordered() + flush_table() + flush_quote() + continue + + heading_match = HEADING_RE.match(line) + if heading_match: + flush_paragraph() + flush_unordered() + flush_ordered() + flush_table() + flush_quote() + heading_index += 1 + level = len(heading_match.group(1)) + text = heading_match.group(2).strip() + anchor = slugify(text, heading_index) + headings.append(Heading(level=level, text=text, anchor=anchor)) + parts.append(f'{render_inline(text)}') + continue + + if line.strip() == "---": + flush_paragraph() + flush_unordered() + flush_ordered() + flush_table() + flush_quote() + parts.append("
    ") + continue + + if line.lstrip().startswith("|"): + flush_paragraph() + flush_unordered() + flush_ordered() + flush_quote() + table_lines.append(line) + continue + + quote_match = BLOCKQUOTE_RE.match(line.strip()) + if quote_match: + flush_paragraph() + flush_unordered() + flush_ordered() + flush_table() + quote_lines.append(quote_match.group(1).strip()) + continue + + if UNORDERED_ITEM_RE.match(line.strip()): + flush_paragraph() + flush_ordered() + flush_table() + flush_quote() + unordered_items.append(line.strip()) + continue + + if ORDERED_ITEM_RE.match(line.strip()): + flush_paragraph() + flush_unordered() + flush_table() + flush_quote() + ordered_items.append(line.strip()) + continue + + flush_unordered() + flush_ordered() + flush_table() + flush_quote() + paragraph.append(line.strip()) + + if in_code_block: + parts.append(render_code_block(code_lines, code_language)) + flush_paragraph() + flush_unordered() + flush_ordered() + flush_table() + flush_quote() + return "\n".join(parts), headings + + +def build_toc(headings: Iterable[Heading]) -> str: + items = [heading for heading in headings if heading.level in (2, 3, 4)] + if not items: + return "" + lines = ['"]) + return "\n".join(lines) + + +def render_meta_list(metadata: dict[str, str]) -> str: + preferred = ["报告日期", "行业", "公司", "覆盖范围"] + items = [] + for key in preferred: + if key in metadata: + items.append((key, metadata[key])) + for key, value in metadata.items(): + if key in {"title", "report_date"} or key in preferred: + continue + items.append((key, value)) + if not items: + items = [("报告日期", metadata.get("report_date", "未提供"))] + return "
      " + "".join( + f"
    • {html.escape(key)} {render_inline(value)}
    • " for key, value in items + ) + "
    " + + +def build_html_document(title: str, subtitle: str, report_date: str, metadata: dict[str, str], toc_html: str, body_html: str, css: str) -> str: + meta_chips = "".join( + f'{html.escape(label)} {render_inline(value)}' + for label, value in [("Report Date", report_date), ("Format", "Standalone HTML"), ("Source", "RQ Research Markdown")] + ) + return f""" + + + + + {html.escape(title)} + + + +
    + + +
    +
    +

    Research Output

    +

    {html.escape(title)}

    +

    {render_inline(subtitle)}

    +
    {meta_chips}
    +
    + +
    +{body_html} + +
    +
    +
    + + +""" + + +def resolve_output_path(args: argparse.Namespace, input_path: Path) -> Path: + return Path(args.output).expanduser() if args.output else input_path.with_suffix(".html") + + +def infer_subtitle(metadata: dict[str, str]) -> str: + for key in ("行业", "公司", "覆盖范围"): + if key in metadata: + return f"{key}:{metadata[key]}" + return "由 Markdown 研究报告自动渲染的单文件 HTML。" + + +def try_open(path: Path) -> None: + commands = (["xdg-open", str(path)], ["open", str(path)]) + for command in commands: + try: + subprocess.run(command, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + return + except (FileNotFoundError, subprocess.CalledProcessError): + continue + print("警告:未找到可用的浏览器打开命令,已保留 HTML 文件。") + + +def main() -> None: + args = parse_args() + input_path = Path(args.input).expanduser() + if not input_path.exists(): + raise FileNotFoundError(f"输入 Markdown 不存在:{input_path}") + + markdown_text = input_path.read_text(encoding="utf-8") + metadata = parse_metadata(markdown_text, input_path) + title = args.title or metadata.get("title") or input_path.stem + report_date = metadata.get("report_date", "未提供") + subtitle = infer_subtitle(metadata) + body_html, headings = render_markdown(markdown_text) + toc_html = build_toc(headings) + css = load_css(Path(__file__)) + output_html = build_html_document(title, subtitle, report_date, metadata, toc_html, body_html, css) + + output_path = resolve_output_path(args, input_path) + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(output_html, encoding="utf-8") + print(f"✅ HTML 报告已生成:{output_path}") + + if args.open: + try_open(output_path) + + +if __name__ == "__main__": + main() diff --git a/.cursor/skills/ricequant-skills-README.md b/.cursor/skills/ricequant-skills-README.md new file mode 100644 index 000000000..261cff488 --- /dev/null +++ b/.cursor/skills/ricequant-skills-README.md @@ -0,0 +1,85 @@ +# ricequant-skills + +Skills developed by RiceQuant for use with Claude Code. + +## 目录结构 + +``` +skills/ +├── basic/ +│ ├── ricequant/ # Ricequant 平台通用文档查询 +│ ├── rqdata-python/ # RQData Python API 查询技能 +│ └── rqams/ # RQAMS API统一入口,包含 CLI 与 Python SDK +└── research/ # 股票研究报告技能(依赖 RQData CLI) + ├── catalyst-calendar/ # 催化剂日历 + ├── earnings-analysis/ # 财报分析 + ├── earnings-preview/ # 财报预览 + ├── idea-generation/ # 投资创意生成 + ├── initiating-coverage/ # 首次覆盖研究 + ├── morning-note/ # 晨会纪要 + ├── report-renderer/ # HTML 报告渲染 + ├── sector-overview/ # 行业概览 + └── thesis-tracker/ # 投资论文跟踪 +``` + +## Skills + +### Basic + +#### `ricequant` + +Ricequant 平台通用文档查询工具。通过在线文档自动检索,覆盖以下组件: + +| 组件 | 说明 | +|---|---| +| RQAlphaPlus | 回测框架——参数配置、交易接口、数据查询接口 | +| RQData | 数据 API——A 股、港股、期货、期权、指数、基金、可转债 | +| RQFactor | 因子计算——内置因子、内置算子、自定义算子 | +| RQOptimizer | 优化器——选股 API、投资组合优化 | +| RQPAttr | 归因分析——Brinson 行业归因、因子归因 | +| RQSDK | 本地开发套件——环境配置、组件集成 | +| RQAMS / RQAMSC | 资产管理系统与 Python SDK——产品、工作空间、交易流水、估值表、持仓、分析与自动化接口 | + +**注意:** 需联网访问 `ricequant.com` 文档。`document-index.txt` 未列出 RQAMS,查询 RQAMS / RQAMSC 时需直接访问 `https://www.ricequant.com/doc/rqams/` 或 `https://www.ricequant.com/doc/rqamsc/`。 + +--- + +#### `rqdata-python` + +RQData 数据 API 使用指南。支持 A 股、港股、期货、期权、指数、基金、可转债等市场数据查询,包含 HTTP API 和 Python API 文档。本地缓存文档,无需联网即可查询。 + +**前置要求:** 正确安装 rqsdk 并配置许可证。 + +--- + +#### `rqams` + +RQAMS API统一入口。常规产品、工作空间、交易流水、估值表、持仓报表、模拟交易、对账、报表和分析任务优先使用 `rqamsc` CLI;需要自定义 Python 脚本、多 API 组合、本地环境诊断或 CLI 未覆盖接口时使用 `rqamsc-python`。 + +**前置要求:** CLI 路径需可用的 `rqamsc` 命令,CLI 文档缓存由 `skills/basic/rqams/rqams-cli/scripts/init_skill.py` 从 GitHub 生成;Python 路径需可用的 `rqamsc` Python SDK,具体安装、升级和环境配置以 `skills/basic/rqams/rqamsc-python/` 下的专属文档为准。 + +--- + +### Research + +> **注意:** 所有 research skills 依赖 **RQData CLI**(`rqdata` 命令行工具),请确保已正确安装并配置后再使用。 +> RQData CLI github 仓库:https://github.com/ricequant/rqdata-cli +> RQData CLI 安装命令:`npm install -g @ricequant2026/rqdata-cli` + +所有 research skills 均遵循三阶段流程:**数据采集 → 报告生成 → HTML 渲染**,输出专业可读的研究报告。 + +| Skill | 说明 | +|---|---| +| `catalyst-calendar` | 催化剂日历——追踪覆盖股票池未来 30 天的财报、分红、公告等重要事件 | +| `earnings-analysis` | 财报分析——财报披露后基于真实财务数据、市场预期和股价反应完成结构化复盘 | +| `earnings-preview` | 财报预览——财报发布前基于历史财务、一致预期和研报口径构建可追踪的预判框架 | +| `idea-generation` | 投资创意生成——系统化量化筛选,输出价值、成长、质量三类候选股 | +| `initiating-coverage` | 首次覆盖研究——基于财务、股权、交易、分红、可比公司等数据输出长篇结构化研究报告 | +| `morning-note` | 晨会纪要——汇总隔夜公告、财务更新、昨日股价表现与今日重点观察名单 | +| `report-renderer` | HTML 渲染——将其他 skill 生成的 Markdown 报告渲染为专业可浏览的 HTML 文档 | +| `sector-overview` | 行业概览——基于行业股票池与真实财务/估值/价格数据完成行业层面结构化分析 | +| `thesis-tracker` | 投资论文跟踪——系统化跟踪核心观点、关键支柱、催化剂和风险信号 | + +## 示例输出 + +`research-example/` 目录包含各 research skill 的示例输出文件,可供参考。 diff --git a/.cursor/skills/ricequant/SKILL.md b/.cursor/skills/ricequant/SKILL.md new file mode 100644 index 000000000..c421548a7 --- /dev/null +++ b/.cursor/skills/ricequant/SKILL.md @@ -0,0 +1,176 @@ +--- +name: ricequant +description: "Ricequant量化平台文档阅读和API查询工具。当Claude需要访问Ricequant量化工具时使用,特别是提到某类金融相关数据或者RQ等字眼时:包括RQAlphaPlus回测框架、RQData数据API、RQFactor因子计算、RQOptimizer优化器、RQPAttr归因分析、RQAMS资产管理系统、RQAMSC Python SDK等文档的查询和阅读。" +license: Proprietary. LICENSE.txt has complete terms +--- + +# Ricequant文档阅读和API查询 + +## 概述 + +Ricequant(米筐)是一个专业的量化交易平台,提供完整的量化研究、回测和交易工具套件。当用户需要查询Ricequant相关文档、API使用方法或示例代码时,可以使用此skill。该skill能够自动检索Ricequant的官方文档,提取相关信息,并以结构化的方式提供给用户,帮助他们更高效地使用Ricequant平台进行量化研究和交易。 + +## 启动条件与方式 +当用户提到与Ricequant相关的查询时,自动触发此skill, +启用一个subagent来处理Ricequant文档的检索和解析工作,讲查到的内容进行结构化整理,并给主Agent提供清晰的查询结果。 + +## 文档结构 + +Ricequant文档主要分为以下几个核心组件: + +### 1. RQAlphaPlus - 回测框架 +- **参数配置**: 各种类型的详尽的参数配置 +- **入口函数**: 用于运行回测的函数 +- **约定函数**: 策略中可选实现的函数 +- **交易接口**: 策略中用于创建订单的函数 +- **数据查询接口**: 策略中用于查询行情数据、财务数据等的函数 + +### 2. RQData - 数据API +- **HTTP API**: 通过HTTP接口的数据获取 +- **Python API**: Python客户端数据查询接口 +- **数据范围**: A股、港股、期货、期权、指数、基金、可转债等 + +### 3. RQFactor - 因子计算 +- **内置因子**: 行情、财务、技术类因子 +- **内置算子**: 数学运算、时间序列、横截面算子 +- **自定义算子**: 开发自定义因子的方法 + +### 4. RQOptimizer - 优化器 +- **选股API**: 股票选择和权重优化 +- **优化器API**: 投资组合优化功能 + +### 5. RQPAttr - 归因分析 +- **归因API**: 业绩归因分析工具 +- **归因模型**: Brinson行业归因和因子归因 + +### 6. RQSDK - 本地开发套件 +- **操作手册**: 本地开发环境配置 +- **组件集成**: 各组件协同工作指南 + +### 7. RQAMS / RQAMSC - 资产管理系统与 Python SDK +- **RQAMS Web 文档**: 产品、工作空间、实时监控、持仓分析、风险归因等资产管理系统功能 +- **RQAMSC 文档**: Python SDK 安装、初始化、产品、工作空间、交易流水、估值表、头寸、分析指标等接口 + +## 文档访问方法 + +### 1. 获取文档索引 +```bash +curl -s "https://www.ricequant.com/doc/document-index.txt" +``` +通用查询流程是:先用 `document-index.txt` 发现文档标题和 `/doc/sources/...` Markdown 源地址,再按命中的源地址读取正文。若目标组件已在站点导航中公开,但未出现在 `document-index.txt` 中,则按组件文档入口补充定位。 + +RQAMS / RQAMSC 属于这种补充入口: + +```bash +curl -L -s "https://www.ricequant.com/doc/rqams/" +curl -L -s "https://www.ricequant.com/doc/rqamsc/" +curl -L -s "https://www.ricequant.com/doc/sources/rqamsc/index.md" +``` + +### 2. 使用curl参数找 +```bash +# 获取并保存到文件 +curl -s "https://www.ricequant.com/doc/sources/rqalpha-plus/api/config.md" -o config.md + +# 显示HTTP状态码 +curl -s -o /dev/null -w "%{http_code}" "https://www.ricequant.com/doc/document-index.txt" + +# 跟随重定向 +curl -L -s "https://www.ricequant.com/doc/document-index.txt" +``` + +## 常见查询场景 + +### 场景1:查询API使用方法 +当用户询问如何调用Ricequant的特定API时: +1. 首先获取文档索引了解API所属组件 +2. 根据组件找到对应的API文档 +3. 提取相关API的使用示例和参数说明 + +### 场景2:查找配置参数 +当用户需要配置回测参数时: +1. 访问RQAlphaPlus的参数配置文档 +2. 查找具体的参数名称和取值范围 +3. 提供配置示例 + +### 场景3:获取数据字段说明 +当用户需要了解数据字段含义时: +1. 访问RQData对应市场的API文档 +2. 查找数据字段的定义和说明 +3. 提供数据获取示例代码 + +### 场景4:因子开发指导 +当用户需要开发自定义因子时: +1. 访问RQFactor的自定义算子文档 +2. 了解因子开发框架和接口 +3. 提供开发示例 + +## 工作流程 + +### 步骤1:确定需求 +- 明确用户需要查询的具体内容(API、配置、数据字段等) +- 确定所属的Ricequant组件 +- 如果需求属于 RQAMS / RQAMSC,先尝试按通用索引流程定位;索引未命中时,使用 RQAMS / RQAMSC 组件入口或 RQAMSC Markdown 源文档 +- 绝大部分的查询都是针对RQdata的数据API,其他组件的查询相对较少。无法判断的情况直接查询RQdata的文档索引,看看是否有相关内容。 + +### 步骤2:获取文档 +- 使用curl获取相关文档内容 +- 如果直接访问失败,尝试不同的URL格式或检查网络连接 + +### 步骤3:提取信息 +- 从文档中提取相关部分 +- 整理成清晰的结构化信息 +- 提供代码示例和参数说明 + +### 步骤4:验证信息 +- 检查信息的准确性和完整性 +- 确保提供的示例代码可运行 +- 注明文档版本和更新日期 + +## 注意事项 + +### 1. 文档版本 +- Ricequant文档会定期更新,注意检查文档的更新日期 +- 不同版本的API可能有差异,需要确认用户使用的版本 + +### 2. 网络访问 +- 如果直接使用WebFetch工具失败,优先使用curl命令 +- 确保网络可以访问ricequant.com域名 + +### 3.使用范围 +- 绝大部分的API查询都是针对RQdata的数据API,其他组件的查询相对较少。 +- RQDATA查询内容包括:API使用方法、参数配置、数据字段,示例。 +- RQAMS / RQAMSC 查询内容包括:Web 功能说明、Python SDK 安装与初始化、产品和工作空间、交易流水、估值表、头寸、持仓与分析接口。 + +## 示例查询 + +### 示例1:查询回测配置参数 +```bash +# 获取参数配置文档 +curl -s "https://www.ricequant.com/doc/sources/rqalpha-plus/api/config.md" | grep -A5 -B5 "benchmark" +``` + +### 示例2:查询股票数据API +```bash +# 获取A股数据API文档 +curl -s "https://www.ricequant.com/doc/sources/rqdata/python/stock-mod.md" | grep -A10 -B5 "get_price" +``` + +### 示例3:查询因子计算示例 +```bash +# 获取因子计算文档 +curl -s "https://www.ricequant.com/doc/sources/rqfactor/api/factor-calculation.md" | grep -A15 -B5 "execute_factor" +``` + +## 故障排除 + +### 1. 文档访问失败 +- 检查网络连接:`ping www.ricequant.com` +- 尝试使用代理或VPN +- 检查URL是否正确 + +### 2. 内容解析错误 +- 确认文档格式为Markdown +- 检查字符编码(通常为UTF-8) +- 使用`iconv`转换编码(如果需要) + diff --git a/.cursor/skills/rqams/SKILL.md b/.cursor/skills/rqams/SKILL.md new file mode 100644 index 000000000..8d08bfdfc --- /dev/null +++ b/.cursor/skills/rqams/SKILL.md @@ -0,0 +1,133 @@ +--- +name: rqams +description: 处理 RQAMS 数据查询和操作时使用。标准产品、workspace、交易流水、估值表、持仓报表、模拟交易、对账、报表和分析任务优先路由到 rqams-cli;需要 Python SDK 脚本、多 API 组合处理、本地 Python 环境诊断或 CLI 未覆盖接口时路由到 rqamsc-python。 +--- + +# RQAMS + +此 skill 是 RQAMS 的统一入口,负责在两个执行路径之间选择: + +- `rqams-cli/`:使用本地 `rqamsc` CLI 处理标准 RQAMS 操作。 +- `rqamsc-python/`:使用 Python SDK 编写脚本、处理复杂工作流、诊断环境,或调用 CLI 尚未覆盖的接口。 + +处理具体 RQAMS 任务前,先按 `references/ams_valuation_flow.md` 理解用户需求在 AMS 估值主链路中的位置:对象层、输入层、计算层或结果层。确定业务位置后,再路由到 CLI 或 Python 路径。 + +涉及托管事件的创建、更新、上传、删除、排查,或用户提到申购、赎回、分红、费用、现金/应收应付调整时,必须先阅读 `references/custodian_event_workflow.md`。该文档说明托管事件写入前的日期、金额、申赎开放日和申赎单位净值确认规则。 + +涉及对账、reconciliation、估值表差异、持仓差异、净值差异、现金/应收应付差异或需要判断是否使用 `auto` 对账时,必须先阅读 `references/reconciliation_workflow.md`。该文档是工具无关的对账处理准则,适用于 `rqams-cli` 和 `rqamsc-python`。 + +## 首次初始化 + +当用户首次安装 `rqams` skill 时,先按以下顺序完成初始化,再处理具体业务任务: + +1. 安装本地工具: + +```powershell +npm install -g @ricequant2026/rqams-cli --include=optional +python -m pip install rqamsc +``` + +如果用户指定了目标 Python 解释器,使用该解释器执行 `-m pip install rqamsc`;如果设置了 `RQAMSC_PYTHON`,后续 Python 路径也以该解释器为准。安装后必须验证: + +```powershell +rqamsc --version +python -c "import rqamsc; print(rqamsc.__file__)" +``` + +2. 初始化 skill 文档缓存。分别进入两个子 skill 目录运行初始化脚本,不要假设 `cache/` 已经存在: + +```powershell +cd rqams-cli +python scripts/init_skill.py +cd ..\rqamsc-python +python scripts/init_skill.py --show-env +cd .. +``` + +若需要强制刷新文档缓存,使用对应子目录下的 `python scripts/init_skill.py --force-refresh`。 + +3. 配置共享 profile。CLI 和 Python SDK 统一使用 `profile` 管理登录态和 workspace。Agent 负责执行配置,不要让用户自己输入命令。默认不要在聊天中索取密码;agent 先确认非敏感字段,包括 profile 名称、AMS 服务地址、用户名,以及可选的 workspace 名称或 ID,然后在本地临时目录生成登录 payload 模板文件,让用户直接在该文件中补全或修正敏感字段。 + +```powershell +rqamsc setup --payload @D:\tmp\rqams_setup_.json +``` + +登录 payload 模板文件的推荐位置是 `D:\tmp\rqams_setup_.json`;如果 `D:\tmp` 不可用,则使用系统临时目录下的同名文件。Agent 创建模板后必须告知用户完整路径,并说明只需要填写或确认以下 JSON 字段: + +```json +{ + "profile": "profile-name", + "base_url": "https://...", + "username": "account", + "password": "fill-password-here", + "workspace_name_or_id": "optional-workspace" +} +``` + +用户确认文件已填写后,agent 执行 `rqamsc setup --payload @`。`rqamsc setup` 会把该 profile 的登录态保存到本地 CLI 配置中,便于后续 CLI 命令和 Python SDK 复用。后续 CLI 命令在 payload 顶层传同一个 `profile`;Python SDK 路径通过 `RQAMSC_PROFILE` 选择同一个 profile。Agent 处理凭据时必须避免在回复、仓库文件、skill 文件或可长期保留的日志中回显真实账号、密码或 session;临时 payload 文件只用于本次配置,配置完成后删除。若用户未提供 workspace,先完成登录,再用只读命令查询 workspace 列表并让用户选择,随后由 agent 执行 workspace 配置。 + +4. 初始化完成后,用只读命令确认状态: + +```powershell +rqamsc get current-workspace --payload '{"profile":"..."}' +rqamsc schema list +``` + +如果后续任务走 Python SDK 路径,按 `rqamsc-python/SKILL.md` 的运行时初始化规则设置 `RQAMSC_PROFILE`,让 Python 使用同一个 profile。不要维护另一套账号密码环境变量;账号、密码、AMS 地址和 workspace 只来自共享 profile。 + +## 路由规则 + +当用户要查询、创建、更新、删除、上传、下载、重算或汇总标准 RQAMS 资源时,优先使用 `rqams-cli`。标准资源包括 workspace、产品、产品组、交易流水、估值表、持仓报表、托管事件、份额事件、自定义对象、模拟交易、对账、分析和报表。 + +当用户明确要求 Python 代码、需要可复用脚本或 notebook 式流程、需要组合多个 API 做自定义处理、需要检查本地 Python `rqamsc` 环境,或 CLI schema 未覆盖目标 API 时,使用 `rqamsc-python`。 + +如果两条路径都能完成任务,一次性操作使用 CLI,复杂数据处理使用 Python。 + +## 批量查询策略 + +当目标接口支持批量查询多个产品、产品组、日期、ID、名称或其他资源标识时,优先使用批量参数,而不是逐个资源串行查询。若用户没有指定批量大小,默认每批 10 个标的;每批完成后汇总结果,再继续下一批。只有接口文档、运行时 schema、服务端报错或用户明确要求显示单次请求数量限制时,才调整批量大小。 + +分批查询时要保留输入顺序和失败项信息:成功结果按原始输入标识合并;单批失败时先记录该批输入、错误码和错误消息,再根据错误类型判断是否缩小批量或改为逐项重试。不要在顶层维护具体接口的批量字段名,字段名仍以 `rqams-cli` 运行时 schema、CLI 缓存文档或 `rqamsc-python` API 文档为准。 + +## 版本与字段来源 + +顶层 `rqams` 只维护工具选择、业务链路判断和通用安全准则,不维护具体命令字段、API 字段或产品创建模板字段。 + +`rqams-cli` 和 `rqamsc-python` 可能连接不同版本的工具、SDK 或后端服务,字段和默认模板允许各自演进。选择执行路径后,以该路径自己的运行时 schema、缓存文档和版本检查结果为准;不要为了保持 CLI 与 Python 一致而补齐或改写字段。 + +通用的结果查询决策标准可以复用,例如先判断用户要快照、时间序列、横截面汇总、明细列表还是分析结果;但具体可用字段、默认值、批量能力和返回结构必须回到对应子路径确认。 + +## CLI 路径 + +使用 CLI 路径前,先阅读 `rqams-cli/SKILL.md`。 + +首次安装或重装 skill 后,不要假设 `rqams-cli/cache/` 已存在;按子 skill 要求先运行 `python scripts/init_skill.py` 生成文档索引。如果本机未安装 `rqamsc`,按子 skill 的 bootstrap 规则先安装 CLI 并验证 `rqamsc --version`。首次配置 RQAMS 登录态时,由 agent 创建本地登录 payload 模板文件并告知用户路径;用户在文件里填写密码后,agent 执行 `rqamsc setup`。不要要求用户手动输入命令或手动设置一组环境变量。后续 CLI 业务 payload 顶层应携带同一个 `profile`。 + +以运行时 schema 作为命令契约: + +```powershell +rqamsc schema list +rqamsc schema get --payload '{"command":"get product-list"}' +``` + +再按统一格式调用: + +```powershell +rqamsc --payload +``` + +## Python 路径 + +使用 Python 路径前,先阅读 `rqamsc-python/SKILL.md`。 + +按子 skill 说明使用 `rqamsc-python/scripts/` 中的脚本初始化或检查 Python 环境。 + +## 安全要求 + +对于创建、更新、删除、上传、重算等写操作,除非用户已经给出精确指令,否则执行前必须明确目标资源和关键 payload 字段。 + +对账写操作必须遵守 `references/reconciliation_workflow.md`:先只读诊断并向用户报告差异、拟写入/删除内容、影响范围和预期效果,用户确认后再执行。 + +托管事件写操作必须遵守 `references/custodian_event_workflow.md`:尤其是申购、赎回事件,不要凭常识推断上一交易日;写入前必须确认或补齐申赎开放日和 4 位申赎单位净值。 + +不要在顶层文件重复维护命令字段或 API 对象 schema。CLI 运行时 schema、`rqams-cli/scripts/init_skill.py` 生成的 CLI 文档缓存、Python 子 skill 初始化脚本生成的索引和通用 reference 才是事实来源。 diff --git a/.cursor/skills/rqams/references/ams_valuation_flow.md b/.cursor/skills/rqams/references/ams_valuation_flow.md new file mode 100644 index 000000000..164945286 --- /dev/null +++ b/.cursor/skills/rqams/references/ams_valuation_flow.md @@ -0,0 +1,92 @@ +# AMS 估值主脉络 + +AMS 的核心不是单纯查报表,而是围绕产品持续维护一条头寸链: + +```text +输入数据 -> 估值计算 -> 头寸 / 净值 / 指标 / 分析结果 +``` + +## 核心估值思路 + +估值计算的基本逻辑是: + +```text +某日头寸 = + 上一日头寸 + + 当日交易/事件变化 + + 当日行情和估值价格 +``` + +如果用户导入了估值表,则估值表可以直接作为某日头寸输入: + +```text +某日头寸 = 当日估值表 +``` + +输入主要分两类: + +- 变动输入:交易流水、托管事件、份额事件、交割单、模拟交易信号 +- 截面输入:估值表、持仓报表、起始日头寸 + +输出主要包括: + +- 头寸结果:balance、asset snapshot、持仓明细 +- 净值、指标和基准:单位净值、收益、回撤、风险指标、自定义指标、自定义基准 +- 分析结果:绩效归因、交易分析、投资概览 +- 运营结果:对账结果、周报等报告 + +## 模块位置 + +对象层: + +- workspace:业务空间,不直接参与估值计算 +- product / product group:估值对象,输入、计算和输出都挂在产品或产品组下 + +输入层: + +- trade / settlement trade:记录产品交易,是头寸变化来源 +- custodian event / unit event:记录非普通交易类变化,例如托管事件、份额变化;申购、赎回等托管事件会影响现金、份额和净值,具体写入前确认和净值补填规则见 `custodian_event_workflow.md` +- valuation report / position statement:提供某一天的完整估值或持仓状态,可作为头寸起点或校准点 +- paper trading signal:模拟交易场景下的特殊输入 +- customized instrument / customized-instrument-price:补充系统默认证券之外的自定义合约和公允价输入 +- valuation report fair value:估值表持仓中的 `fair_value` / `fair_value_setl_ccy` 是估值表字段,不是独立接口;用于上传或覆盖估值表时走 valuation report 流程 + +计算层: + +- recompute balance:从指定日期重新推进头寸链 +- 估值计算:根据上一日头寸、当日变动和价格生成新头寸 +- 实时头寸计算:基于最近可用头寸、盘中流水和实时行情生成实时结果 + +结果层: + +- balance / balance series / asset snapshot:查看某日、某段时间或实时的资产和头寸结果 +- indicator / indicator series:基于头寸和净值进一步计算收益、风险、净值等指标 +- customized indicator:维护产品或产品组下的自定义指标结果口径 +- customized benchmark:维护用于收益比较、归因或概览展示的自定义基准 +- performance attribution / returns decomposition / trading analysis:解释收益来源、交易贡献和组合变化 +- investment overview:从产品组或组合视角做汇总展示 +- reconciliation:比较 AMS 计算结果和外部或托管数据是否一致 +- reports:把估值、净值、指标等结果打包成报告 + +## Agent 判断顺序 + +先判断用户需求落在哪一层,再查具体命令: + +```text +查空间或产品 -> workspace / product / product group +导入流水、估值表、持仓、事件或自定义合约价格 -> 输入层 +录入或修正托管事件 -> 先读 `references/custodian_event_workflow.md` +维护自定义合约公允价 -> customized-instrument-price +处理估值表持仓公允价 -> valuation-report 中的 `fair_value` / `fair_value_setl_ccy` +处理对账价格差异 -> 先读 `references/reconciliation_workflow.md` 中的价格差异规则 +要求重新计算、修正历史结果 -> recompute balance +查某日或实时头寸 -> balance / asset snapshot +查净值、收益、风险指标 -> indicator / indicator series +解释收益或交易贡献 -> attribution / decomposition / trading analysis +维护自定义指标或自定义基准 -> 结果层 +检查数据是否一致 -> reconciliation / 结果层 +下载或生成材料 -> reports / 结果层 +模拟交易相关 -> paper trading +``` + +确定模块后,再阅读 `rqams-cli/SKILL.md` 和 CLI 文档确认命令命名、payload、文件路径字段和输出格式。 diff --git a/.cursor/skills/rqams/references/custodian_event_workflow.md b/.cursor/skills/rqams/references/custodian_event_workflow.md new file mode 100644 index 000000000..110522790 --- /dev/null +++ b/.cursor/skills/rqams/references/custodian_event_workflow.md @@ -0,0 +1,88 @@ +# RQAMS 托管事件处理流程 + +本文是工具无关的托管事件处理准则,适用于 `rqams-cli` 和 `rqamsc-python`。具体命令字段、SDK 函数和接口参数以各工具的运行时 schema、命令文档或 Python API 文档为准。 + +## 适用场景 + +创建、更新、上传、删除或排查托管事件时,先按本文确认事件类型、日期、金额、申赎净值和影响范围。 + +托管事件是估值输入,会影响现金、应收应付、份额、资产净值、单位净值和后续收益。写入前不要只按用户给出的最少字段直接提交。 + +## 写入前检查 + +写入或更新托管事件前,至少确认: + +- 产品对象。 +- 事件类型。 +- 出入账日期。 +- 金额、科目或调整对象。 +- 权益生效日;只有分红和赎回事件支持填写。 +- 对申购、赎回事件,确认申赎开放日和申赎单位净值。 + +如果用户给出的日期语义不清,先确认是出入账日期、权益生效日还是申赎开放日。 + +## 事件类型、字段和估值影响关系 + +托管事件对象的基础字段是 `date`、`custodian_event_type` 和 `amount`。不同事件类型还需要关注不同业务字段,并会在 `portfolio-calc2` 中产生不同的估值影响: + +| 事件类型 | 必须关注字段 | 日期口径 | 估值和份额影响 | 写入说明 | +| --- | --- | --- | --- | --- | +| `subscription_fund_received` | `sr_open_date`、`unit_net_value` | `date` 是申购款入账日。 | 在 `date` 增加现金和净申购金额;按 `amount / unit_net_value` 生成自动申购份额事件。 | 缺申赎开放日或申赎单位净值时,按本文申赎事件补填规则查询前序 balance,并将单位净值四舍五入到 4 位。 | +| `redemption_paid` | `sr_open_date`、`unit_net_value`、`effective_date` | `effective_date` 是赎回权益生效日,`date` 是赎回款实际出账日。用户未给 `effective_date` 时,服务端通常按 `date` 处理。 | 在 `effective_date` 挂应付、减少权益,并按 `amount / unit_net_value` 生成自动赎回份额事件;在 `date` 支付现金并冲回应付。若两日相同,两类影响在同日发生。 | 申赎净值必须写入前明确;不要把出账日、权益生效日和申赎开放日混用。 | +| `product_dividend_paid` | `effective_date` | `effective_date` 是分红权益生效日,`date` 是分红实际出账日。用户未给 `effective_date` 时,服务端通常按 `date` 处理。 | 在 `effective_date` 挂应付并影响单位净值,同时将 `amount / units` 计入累计单位分红;在 `date` 支付现金并冲回应付。不生成份额事件。 | 写入前确认用户给的是分红生效日还是支付日;涉及累计净值时必须说明影响范围。 | +| `product_cost_paid` | `product_cost_type` | `date` 是费用事件日。 | 调整 `product_cost_type` 对应的费用应付科目;不生成份额事件。 | 写入前确认费用类型,不要只用备注表达费用类型;同时注意估值流程中可能存在每日费用计提。 | +| `subject_adjusted` | `adjust_target`、`adjust_operation` | `date` 是科目调整日。 | 按 `adjust_target` 和 `adjust_operation` 直接调整目标科目;不生成份额事件。 | 写入前确认调整科目、调整方向和目标金额;该类型是直接科目修正,风险高于普通现金事件。 | + +不要把上表当作完整接口 schema。具体字段类型、枚举值、批量格式、ID 字段和返回结构仍以当前 `rqams-cli` 运行时 schema、缓存命令文档或 `rqamsc-python` API 文档为准。 + +`portfolio-calc2` 会按事件优先级处理同日多个托管事件,并将 `redemption_paid`、`product_dividend_paid` 同时挂到 `date` 和 `effective_date` 参与计算。自动份额事件是否最终改变产品份额,还取决于估值任务的份额模式;写入前说明影响范围时,应同时说明现金、应付、份额、单位净值和累计净值的预期变化。 + +## 申赎事件补填规则 + +申购和赎回类托管事件包括: + +- `subscription_fund_received` +- `redemption_paid` + +这类事件需要维护申赎开放日 `sr_open_date` 和申赎单位净值 `unit_net_value`。 + +当用户只给出出入账日期,没有给出申赎开放日或申赎单位净值时: + +1. 不要凭 agent 常识或自然日直接推断上一交易日。 +2. 优先用只读查询获取事件日期之前最近一条有 `unit_net_value` 的产品 balance。 +3. 将该 balance 的日期作为申赎开放日参考,将 `unit_net_value` 四舍五入到 4 位后作为申赎单位净值。 +4. 写入前向用户报告采用的申赎开放日、单位净值来源日期、补填后的 4 位单位净值,以及事件金额和影响范围。 + +如果用户明确给出申赎开放日,优先使用用户给出的 `sr_open_date`,但仍需查询该日期或该日期之前最近可用的单位净值,并在报告中说明口径。 + +如果查不到上一条有效 `unit_net_value`,不要自动写入申赎事件;先向用户说明缺少前序净值,确认是先补历史 balance/估值表,还是由用户提供申赎单位净值。 + +服务端可能会在缺少 `sr_open_date` 时按出入账日期默认填前一交易日,但 agent 不应依赖该隐式行为来跳过写入前说明;`unit_net_value` 仍必须在写入前明确。 + +## 只读查询建议 + +优先查询事件日期前一段窗口的 balance series,字段至少包含 `date` 和 `unit_net_value`。选择小于事件出入账日期、且 `unit_net_value` 非空的最近一条记录。 + +如果工具只支持单日 balance 查询,先根据产品日历或可用时间序列定位候选日期;不能可靠定位时,扩大 balance series 查询窗口,而不是猜测节假日。 + +## 写操作前确认 + +任何创建、更新或删除托管事件前,都必须向用户报告: + +- 目标产品和事件日期。 +- 事件类型、金额、科目或调整对象。 +- 申赎事件的申赎开放日和 4 位申赎单位净值。 +- 拟写入、更新或删除的对象。 +- 影响范围,例如会从哪天开始重算,会影响哪些现金、份额、净值或收益。 +- 回滚方式,例如可删除的托管事件 ID。 + +用户确认后才能执行写操作。 + +## 写入后复查 + +写入或修正托管事件后: + +1. 通常等待系统自动触发受影响日期之后的 balance 重算。 +2. 重新查询托管事件确认字段落库。 +3. 再查 balance、份额或对账差异,确认现金、应收应付、份额和净值是否符合预期。 +4. 不要在未复查的情况下标记处理完成。 diff --git a/.cursor/skills/rqams/references/reconciliation_workflow.md b/.cursor/skills/rqams/references/reconciliation_workflow.md new file mode 100644 index 000000000..353e16df4 --- /dev/null +++ b/.cursor/skills/rqams/references/reconciliation_workflow.md @@ -0,0 +1,165 @@ +# RQAMS 通用对账流程 + +本文是工具无关的 RQAMS 对账处理准则,适用于 `rqams-cli` 和 `rqamsc-python`。具体命令字段、SDK 函数和接口参数以各工具的运行时 schema、命令文档或 Python API 文档为准。 + +## 核心原则 + +对账按产品和日期处理。估值表、持仓单和托管数据是外部参照,不是默认覆盖源。 + +### 概念区分:对账覆盖 vs 自动覆盖 AMS 估值 + +“对账”和“使用估值表整体自动覆盖 AMS 估值”是两种不同操作。 + +- 对账中的“覆盖”通常指某个差异项按估值表、持仓单或托管参照数据为准来修正业务输入或标记处理口径。 +- 自动覆盖 AMS 估值通常指通过 `auto` 等操作,直接以估值表作为主数据覆盖当日 AMS 估值结果或倒推相关流水/头寸。 + +如果已经执行整体自动覆盖,并且目标就是让 AMS 估值完全采用估值表口径,则通常不再需要按普通对账流程逐项处理差异。开始处理前必须先确认用户说的“覆盖”是哪一种,避免把普通对账中的差异项修正误当成整体自动覆盖,或把整体自动覆盖误当成普通对账确认。 + +整体自动覆盖后,如果当日或相关期间发生申购、赎回、产品分红等托管事件,仍需要补齐这些业务数据。自动覆盖或倒推不能保证这类事件被完整、准确还原。 + +标准处理顺序是: + +```text +只读诊断 -> 差异分层 -> 向用户报告并确认 -> 修正业务输入 -> 等待或确认 balance 自动重算 -> 复查差异 -> 标记状态 +``` + +不要看到某个字段不一致就直接调整该字段。许多差异有依赖关系:持仓数量、价格、现金、应收应付和份额来源都会传导到净值。`net_asset` 是结果项,通常只用于复核和定位,不建议直接按净资产倒推覆盖。 + +## 只读诊断 + +先执行只读查询,不写入业务数据: + +1. 查询对账状态,确认目标产品、日期、是否缺估值表或持仓单、是否已经自动对账、是否已经人工标记。 +2. 查询具体日期差异,建议同时查看 `positions`、`prices`、`payable`、`receivable`、`cash`、`net_asset`。 +3. 如差异来自资产单元持仓单,再查询最新持仓单状态、资产单元维度差异和持仓单对账结果。 +4. 同步查询相关输入:交易流水、结算流水、托管事件、估值表、持仓单、balance、必要时查询产品配置和估值口径。 + +## 差异依赖顺序 + +诊断和修正时按依赖顺序处理。前置项未对齐前,不要急于解释或调整净值差。 + +### 1. 估值表和持仓单存在性 + +先确认参照数据是否存在、日期是否匹配、产品和资产单元是否正确。 + +常见处理: + +- 缺估值表:上传正确日期估值表;如需覆盖已有估值表,先向用户确认 `replace_dates`。 +- 缺持仓单:上传对应资产单元和日期的持仓单。 +- 估值表或持仓单本身口径错误:应修正外部参照数据,而不是倒推覆盖 AMS。 + +### 2. 持仓数量差异 `positions` + +持仓数量差异通常来自交易流水、结算流水、方向、数量、资产单元或估值表持仓口径不一致。 + +处理顺序: + +1. 查交易流水和结算流水。 +2. 核对买卖方向、数量、日期、账户、资产单元、合约代码和资产类型。 +3. 向用户报告拟补充、修正或删除的流水。 +4. 用户确认后再写入或删除流水。 + +不要直接覆盖已有流水或头寸结果。 + +### 3. 价格差异 `prices` + +价格差异通常来自估值价格、结算价、产品级公允价、自定义价格、汇率、停牌价格或不同估值口径。 + +处理价格差异时,先读取差异项返回的 `price.field`,不要默认所有价格都按 `fair_value` 处理。服务端对账逻辑在估值表持仓包含 `fair_value_setl_ccy` 时,会优先用 `fair_value_setl_ccy` 作为价格差异字段;否则使用 `fair_value`。 + +港股 `.XHKG` 需要特别区分价格字段和币种口径: + +- `price.field` 为 `fair_value_setl_ccy` 时,表示按交易所结算币种价格处理,港股通常是 HKD 价格。 +- `price.field` 为 `fair_value` 时,表示按人民币口径公允价处理。 +- 需要维护产品级公允价时,payload 必须携带 `field`,优先使用差异项返回的 `price.field`,并让 `value` 与该字段口径一致。 +- 如果只掌握人民币口径港股价格,才使用 `field:"fair_value"`;服务端会对 `.XHKG` 按产品港币汇率设置转换后存储。 + +处理方式: + +- 若估值表价格错误,修正估值表或重新上传估值表。 +- 若 AMS 需要采用特定产品级价格,维护产品级公允价。 +- 若是自定义证券或 OTC 合约,维护自定义价格。 +- 若是汇率或停牌价格口径,先确认产品估值设置或对应价格来源。 + +价格调整会影响市值、浮动盈亏、收益和净值。写入前必须向用户说明目标证券、日期、价格字段、币种口径、原 AMS 价格、参照价格和预期净值影响。 + +### 4. 托管科目差异 `cash`、`payable`、`receivable` + +现金、应收、应付差异通常来自申购、赎回、分红、费用、税费、利息、清算款、保证金、托管科目分类或估值表科目口径。 + +处理方式: + +1. 先查托管事件。 +2. 判断是缺事件、事件金额错误、事件类型错误、科目分类错误,还是估值表科目口径错误。 +3. 对缺失或错误的业务输入,优先补充或修正托管事件。 +4. 若估值表科目错误,修正估值表科目。 + +现金和应收应付调整会传导到总资产、总负债、资产净值和单位净值。 + +申购、赎回、分红、费用和科目调整等托管事件的写入前检查,统一遵守 `custodian_event_workflow.md`。申购、赎回事件尤其需要确认或补齐申赎开放日和 4 位申赎单位净值;不要只根据用户给出的日期和金额直接写入。 + +### 5. 份额差异 + +份额通常由申购、赎回等托管事件驱动,不应作为默认独立调整项。 + +处理原则: + +- 先核对申赎类托管事件,补齐或修正后份额通常会自然对齐。 +- 核对申赎类托管事件时,按 `custodian_event_workflow.md` 查询前序单位净值并确认申赎净值口径。 +- 只有在申赎托管事件已经核对并补齐后,份额仍不一致,才考虑单独维护份额事件。 +- 单独份额调整属于例外路径,必须先向用户报告原因、目标份额、影响日期、预期净值影响和回滚方式,并取得确认。 + +### 6. 净值差异 `net_asset` + +净值差异由持仓、市值、现金、应收、应付、份额、托管事件和估值口径共同造成。 + +处理原则: + +- 先拆到明细差异,不直接按净资产倒推覆盖。 +- 前置项对齐后,净值通常应自然对齐。 +- 如果明细都已对齐但净值仍不一致,再检查估值表口径、四舍五入、费用计提、产品配置、币种汇率或系统计算设置。 +- 如果前面所有项目都已对齐但累计单位净值或累计净值仍有偏差,需要根据产品的累计净值计算方式,结合历史头寸、产品分红和历史对账问题继续核对,不要只看当日明细。 + +## 写操作前确认 + +任何写入、更新、删除或覆盖业务数据前,必须先向用户报告并等待确认。报告至少包括: + +- 目标产品和日期。 +- 当前差异项和依赖关系。 +- 拟写入、更新或删除的对象类型。 +- 关键字段和值,例如交易方向、数量、价格、托管事件类型、金额、科目、资产单元、估值表日期、价格字段。 +- 影响范围,例如会从哪天开始重算、会影响哪些净值、收益或持仓。 +- 预期效果,例如哪些差异会消失,哪些差异可能仍保留。 +- 回滚方式,例如可删除的事件 ID、流水 ID、价格记录 ID或可撤销的自动对账动作。 + +用户确认后才能执行写操作。 + +## 修正、重算和复查 + +写入或修正输入后: + +1. 通常不需要显式触发重算。修改交易流水、托管事件、估值表、价格等输入数据后,系统一般会自动触发受影响日期之后的 balance 重算。 +2. 再次查询对账差异。 +3. 如果仍有差异,回到差异分层步骤继续定位。 +4. 不要在未复查的情况下标记完成。 + +只有当自动重算未触发、结果仍是旧版本,或工具/接口文档明确要求手动重算时,才从受影响的最早日期显式触发 balance 重算。显式重算属于写操作或高影响操作时,仍需先说明影响范围并取得用户确认。 + +## 标记对账状态 + +只有在以下任一条件满足时,才可写入人工对账状态: + +- 差异已经消除。 +- 差异仍存在,但用户确认可以接受,并要求标记。 + +标记时说明处理结果。若仍有差异,备注应清楚写明剩余差异和用户确认口径。 + +## 自动对账边界 + +`auto` 对账是高风险操作。它会使用估值表覆盖当日流水或头寸相关结果,并可能生成由估值表倒推的流水。 + +通常不建议对已经维护交易流水的产品使用 `auto`。只有在用户明确确认该日期应以估值表为主数据,或产品本身采用估值表驱动且当日流水无需作为主数据保留时,才可执行。 + +执行自动覆盖前,如果只读诊断发现差异可能由申购、赎回或产品分红托管事件导致,可以向用户说明可尝试由估值表倒推相关结果,但必须提示倒推不保证业务事件完整准确,并请用户确认当日是否实际发生这些事件。若确认发生,应优先补齐或修正托管事件;即使后续仍执行自动覆盖,也不能把倒推结果视为已经完成托管事件维护。 + +误操作后可使用 `undo_auto` 撤销自动对账生成的覆盖或倒推结果。 diff --git a/.cursor/skills/rqams/rqams-cli/SKILL.md b/.cursor/skills/rqams/rqams-cli/SKILL.md new file mode 100644 index 000000000..490133e01 --- /dev/null +++ b/.cursor/skills/rqams/rqams-cli/SKILL.md @@ -0,0 +1,120 @@ +--- +name: rqams-cli +description: 使用本地 rqamsc CLI 查询和操作 RQAMS 时使用,覆盖产品、workspace、交易流水、估值表、持仓报表、模拟交易、对账、报表和分析数据等标准任务。 +--- + +# RQAMS CLI + +当用户请求可由本地 `rqamsc` 命令完成的 RQAMS 数据查询或操作时,使用此 skill。 + +## 使用方法 + +### 文档缓存 + +查文档前,先在 skill 目录运行: + +```powershell +python scripts/init_skill.py +``` + +安装 skill 时不会自动生成 `cache/` 下的文档和索引。每次首次使用本 skill 查询 CLI 文档前,必须先运行上述普通初始化命令;如果该命令首次耗时较长,通常是在同步远端文档,不代表 RQAMS 业务接口慢。 + +`init_skill.py` 管理 CLI 文档缓存。它会复用本地 `cache/`;只有文档或命令索引缺失、缓存超过 7 天,或显式传入 `--force-refresh` 时,才从 GitHub 同步 CLI 文档到本地 `cache/` 并生成命令索引。 + +常用命令: +- 普通查文档:`python scripts/init_skill.py` +- 强制刷新文档:`python scripts/init_skill.py --force-refresh` + +### 前置检查 + +先确认 CLI 可用: + +```shell +rqamsc --version +``` + +如果本机没有 `rqamsc`,先引导或执行 CLI 安装,再继续后续步骤。默认 bootstrap 命令: + +```powershell +npm install -g @ricequant2026/rqams-cli --include=optional +``` + +安装完成后必须重新运行 `rqamsc --version` 验证。若安装失败或提示平台包缺失,再查 `README.md` 或缓存文档中的安装排错;不要把更多安装分支复制到 skill 中。需要联网或全局写入时,按当前工具权限机制请求用户确认。 + +首次配置登录态和 workspace 时,使用 profile 作为统一配置句柄。Agent 负责配置,不要让用户自己输入命令,也不要默认在聊天中索取密码。Agent 先确认 profile、AMS 服务地址、用户名和 workspace 等非敏感信息,再在本地临时目录生成登录 payload 模板文件,让用户直接在文件中填写密码。 + +```shell +rqamsc setup --payload @D:\tmp\rqams_setup_.json +``` + +登录 payload 模板文件推荐放在 `D:\tmp\rqams_setup_.json`;如果 `D:\tmp` 不可用,则使用系统临时目录下的同名文件。Agent 创建模板后必须告知用户完整路径,并说明需要填写或确认的 JSON 字段。payload 必须包含 `profile`,例如: + +```json +{"profile":"acct-a","base_url":"https://...","username":"account","password":"fill-password-here","workspace_name_or_id":"optional-workspace"} +``` + +用户确认文件已填写后,agent 执行 `rqamsc setup --payload @`。`setup` 会把该 profile 的登录态和 workspace 保存到本地配置。后续 CLI 业务命令在 payload 顶层传同一个 `profile`;Python SDK 通过 `RQAMSC_PROFILE` 复用同一个 profile。Agent 不要把真实账号、密码或 session 写入回复、仓库文件、skill 文件或长期日志;临时 payload 文件只用于配置,配置完成后删除。 + +### 事实来源 + +不要在 skill 中重复维护完整命令字段、安装命令或业务 payload 结构。事实来源按用途使用: + +1. CLI 运行时 schema:当前二进制的机器可执行契约,用来确认目标命令是否存在、payload 字段形状、输出格式能力和兼容性。 +2. 本地生成的缓存文档:业务语义、人类可读说明、调用协议、文件路径字段、示例和排错建议,位于 `cache/docs/`。 +3. GitHub 文档:缓存刷新来源,地址为 `https://github.com/ricequant/rqams-cli/tree/master/docs`;日常查阅优先使用本地缓存。 + +运行时 schema 示例: + +```powershell +rqamsc schema list +rqamsc schema get --payload '{"command":"get product-list"}' +``` + +执行任何非 `schema` / `--version` 的业务命令前,必须先查本地缓存文档确认命令命名、请求 payload、文件路径字段、输出 envelope/NDJSON 和业务数据格式;不要只根据 schema 字段名猜测接口语义: + +- `cache/docs/rqams_cli_manual.md` +- `cache/docs/commands/*.md` +- `cache/doc_index/command_index.md` + +其中 `cache/docs/rqams_cli_manual.md` 是总入口,解释统一调用协议、命令发现、认证与 workspace、文件上传/下载和 Agent 使用建议;`cache/docs/commands/*.md` 按业务域说明具体命令、字段和示例;`cache/doc_index/command_index.md` 用于快速定位命令所在文档和行号。若文档与运行时 schema 不一致,执行层面优先按运行时 schema,业务解释和示例仍参考缓存文档,并在回复中说明差异。 + +产品创建默认模板如果由 CLI 提供,模板字段和值只在 CLI 实现和 CLI 产品文档中维护。不要在 skill 中复写模板字段表,也不要要求它与 `rqamsc-python` 的 API 字段或模板保持一致;不同版本字段不一致时,以当前 CLI 运行时 schema 和 CLI 文档为准。 + +处理托管事件创建、更新、上传或删除前,先阅读上级 skill 的 `references/custodian_event_workflow.md`。申购、赎回事件写入前必须确认或补齐申赎开放日和 4 位申赎单位净值;具体 CLI 字段仍以运行时 schema 和缓存文档为准。 + +### 命令调用 + +统一命令格式: + +```text +rqamsc --payload +``` + +优先使用 JSON payload: + +```shell +rqamsc --payload '{"field":"value"}' +``` + +Windows `cmd.exe` 不适合直接写复杂 inline JSON;优先改用 PowerShell,或把 payload 写入文件后使用 `--payload @payload.json`。 + +payload 较大时,使用文件或 stdin: + +```powershell +rqamsc --payload @payload.json +Get-Content -Raw payload.json | rqamsc --payload - +``` + +默认输出是 JSON envelope。只有 `schema get` 显示命令支持 NDJSON 时,才使用 `"format":"ndjson"`。 + +### 批量查询 + +如果运行时 schema 或缓存文档显示命令支持批量字段,优先使用批量查询。用户未指定批量大小时,默认每批 10 个标的,并在 payload 顶层持续携带同一个 `profile`。每批结果按原始输入顺序合并;单批失败时记录该批输入和 JSON envelope 中的 `error.code` / `error.message`,必要时缩小批量或逐项重试。 + +### 安全要求 + +对于创建、更新、删除、上传、重算等写操作,除非用户已给出精确指令,否则执行命令前必须明确目标资源和关键 payload 字段。 + +托管事件写操作还必须遵守上级 `references/custodian_event_workflow.md`。不要只根据用户提供的日期和金额直接写入申购、赎回事件;先查询前序净值、说明补填口径并取得确认。 + +命令失败时,读取 JSON envelope 中的 `error.code` 和 `error.message`,总结可执行的问题和下一步,不要只贴原始日志。 diff --git a/.cursor/skills/rqams/rqams-cli/scripts/generate_indexes.py b/.cursor/skills/rqams/rqams-cli/scripts/generate_indexes.py new file mode 100644 index 000000000..c66db7ec8 --- /dev/null +++ b/.cursor/skills/rqams/rqams-cli/scripts/generate_indexes.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Generate lightweight indexes for cached rqams-cli markdown docs. +""" + +from __future__ import annotations + +import argparse +import re +from pathlib import Path + + +COMMAND_HEADING_PATTERN = re.compile(r"^## `([^`]+)`\s*$") + + +def generate_command_index(docs_dir: Path, output_path: Path) -> None: + commands_dir = docs_dir / "commands" + rows = [ + "# command_index", + "", + "Source: `cache/docs/commands/*.md`", + "", + "| Command | Source | line |", + "| --- | --- | --- |", + ] + + for source_path in sorted(commands_dir.glob("*.md")): + for line_number, line in enumerate(source_path.read_text(encoding="utf-8").splitlines(), start=1): + match = COMMAND_HEADING_PATTERN.match(line) + if not match: + continue + command = match.group(1) + rel_source = f"commands/{source_path.name}" + rows.append(f"| {command} | {rel_source} | {line_number} |") + + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text("\n".join(rows) + "\n", encoding="utf-8") + + +def default_docs_dir() -> Path: + return Path(__file__).resolve().parent.parent / "cache" / "docs" + + +def default_output_path() -> Path: + return Path(__file__).resolve().parent.parent / "cache" / "doc_index" / "command_index.md" + + +def main() -> int: + parser = argparse.ArgumentParser(description="Generate rqams-cli documentation indexes") + parser.add_argument( + "--docs-dir", + type=Path, + default=default_docs_dir(), + help="Path to cached rqams-cli docs directory", + ) + parser.add_argument( + "--output", + type=Path, + default=default_output_path(), + help="Path to generated command index", + ) + args = parser.parse_args() + + generate_command_index(args.docs_dir, args.output) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.cursor/skills/rqams/rqams-cli/scripts/init_skill.py b/.cursor/skills/rqams/rqams-cli/scripts/init_skill.py new file mode 100644 index 000000000..0b5e4fc01 --- /dev/null +++ b/.cursor/skills/rqams/rqams-cli/scripts/init_skill.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Initialize cached rqams-cli docs and regenerate lightweight indexes when needed. +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path +import time +from concurrent.futures import ThreadPoolExecutor, as_completed +from urllib.error import URLError +from urllib.request import urlopen + + +GITHUB_RAW_BASE = "https://raw.githubusercontent.com/ricequant/rqams-cli/master/docs" +DEFAULT_CACHE_DAYS = 7 +DOWNLOAD_TIMEOUT_SECONDS = 60 +DOWNLOAD_RETRIES = 2 +DOC_FILES = [ + "rqams_cli_manual.md", + "commands/analysis.md", + "commands/auth_workspace.md", + "commands/balance.md", + "commands/customized.md", + "commands/customized_benchmark.md", + "commands/customized_indicator.md", + "commands/events.md", + "commands/paper_trading.md", + "commands/permissions.md", + "commands/products.md", + "commands/reconciliation.md", + "commands/reports.md", + "commands/statements_and_valuation.md", + "commands/trades.md", +] +DOWNLOAD_WORKERS = min(15, len(DOC_FILES)) + + +def skill_root() -> Path: + return Path(__file__).resolve().parent.parent + + +def cache_docs_dir() -> Path: + return skill_root() / "cache" / "docs" + + +def command_index_path() -> Path: + return skill_root() / "cache" / "doc_index" / "command_index.md" + + +def cached_doc_paths() -> list[Path]: + return [cache_docs_dir() / rel_path for rel_path in DOC_FILES] + + +def required_cache_paths() -> list[Path]: + return cached_doc_paths() + [command_index_path()] + + +def cache_expired(path: Path, cache_days: int = DEFAULT_CACHE_DAYS) -> bool: + if not path.exists(): + return True + max_age_seconds = cache_days * 24 * 60 * 60 + return time.time() - path.stat().st_mtime > max_age_seconds + + +def download_text( + url: str, + retries: int = DOWNLOAD_RETRIES, + timeout_seconds: int = DOWNLOAD_TIMEOUT_SECONDS, +) -> str: + last_error: Exception | None = None + for attempt in range(1, retries + 2): + try: + with urlopen(url, timeout=timeout_seconds) as response: + return response.read().decode("utf-8") + except (OSError, TimeoutError, URLError) as exc: + last_error = exc + if attempt <= retries: + time.sleep(attempt) + raise RuntimeError(f"Failed to download {url}: {last_error}") from last_error + + +def download_doc(rel_path: str) -> tuple[str, str]: + return rel_path, download_text(f"{GITHUB_RAW_BASE}/{rel_path}") + + +def download_docs(target_dir: Path) -> None: + downloaded_docs: dict[str, str] = {} + failures: dict[str, Exception] = {} + + with ThreadPoolExecutor(max_workers=DOWNLOAD_WORKERS) as executor: + futures = {executor.submit(download_doc, rel_path): rel_path for rel_path in DOC_FILES} + for future in as_completed(futures): + rel_path = futures[future] + try: + downloaded_path, text = future.result() + downloaded_docs[downloaded_path] = text + except Exception as exc: + failures[rel_path] = exc + + if failures: + failure_summary = "; ".join(f"{rel_path}: {exc}" for rel_path, exc in failures.items()) + raise RuntimeError(f"Failed to download {len(failures)} rqams-cli docs: {failure_summary}") + + for rel_path, text in downloaded_docs.items(): + target_path = target_dir / rel_path + target_path.parent.mkdir(parents=True, exist_ok=True) + target_path.write_text(text, encoding="utf-8") + + +def generate_indexes() -> None: + from generate_indexes import generate_command_index + + generate_command_index(cache_docs_dir(), command_index_path()) + + +def refresh_docs() -> str: + target_dir = cache_docs_dir() + target_dir.mkdir(parents=True, exist_ok=True) + + try: + print( + "[INFO] Downloading rqams-cli docs: " + f"{len(DOC_FILES)} files from {GITHUB_RAW_BASE} with {DOWNLOAD_WORKERS} workers" + ) + download_docs(target_dir) + except RuntimeError as exc: + raise RuntimeError(f"Failed to download rqams-cli docs from GitHub: {exc}") from exc + return f"GitHub raw: {GITHUB_RAW_BASE}" + + +def cached_docs_available() -> bool: + return all(path.exists() for path in cached_doc_paths()) + + +def missing_paths(paths: list[Path]) -> list[Path]: + return [path for path in paths if not path.exists()] + + +def expired_paths(paths: list[Path]) -> list[Path]: + return [path for path in paths if path.exists() and cache_expired(path)] + + +def main() -> int: + parser = argparse.ArgumentParser(description="Initialize rqams-cli skill cache") + parser.add_argument( + "--force-refresh", + action="store_true", + help="Force refresh cached docs and indexes", + ) + args = parser.parse_args() + + required_paths = required_cache_paths() + missing_required_paths = missing_paths(required_paths) + expired_required_paths = expired_paths(required_paths) + needs_refresh = args.force_refresh or bool(missing_required_paths or expired_required_paths) + + print( + "[INFO] rqams-cli init: " + f"docs={len(DOC_FILES)}, workers={DOWNLOAD_WORKERS}, " + f"timeout={DOWNLOAD_TIMEOUT_SECONDS}s, retries={DOWNLOAD_RETRIES}" + ) + if needs_refresh: + reason_parts = [] + if args.force_refresh: + reason_parts.append("force refresh requested") + if missing_required_paths: + reason_parts.append(f"{len(missing_required_paths)} cache files missing") + if expired_required_paths: + reason_parts.append(f"{len(expired_required_paths)} cache files expired") + print(f"[INFO] Refresh needed: {', '.join(reason_parts)}") + + try: + source_description = refresh_docs() + print(f"[INFO] Refreshed rqams-cli docs cache from {source_description}") + generate_indexes() + print("[INFO] Regenerated command index") + except RuntimeError as exc: + if not cached_docs_available(): + print(f"[FAIL] {exc}", file=sys.stderr) + print("[FAIL] No complete cached docs are available for fallback", file=sys.stderr) + return 1 + + print(f"[WARN] {exc}") + print("[WARN] Using existing cached rqams-cli docs") + if not command_index_path().exists(): + generate_indexes() + print("[INFO] Regenerated command index from existing cached docs") + else: + print("[INFO] Cache is fresh; using existing docs and command index") + + print("Done") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.cursor/skills/rqams/rqamsc-python/SKILL.md b/.cursor/skills/rqams/rqamsc-python/SKILL.md new file mode 100644 index 000000000..63d7816de --- /dev/null +++ b/.cursor/skills/rqams/rqamsc-python/SKILL.md @@ -0,0 +1,106 @@ +--- +name: rqamsc-python +description: 使用 rqamsc Python SDK 查询 API、编写最小脚本、导入交易流水和结算流水、定位工作空间与初始化问题。当任务涉及 RQAMS 产品、产品组、头寸、交易流水、估值表或相关 Python 接口时使用。 +--- + +# rqamsc-python + +## 使用方法 + +### 文档缓存 + +查文档前,先在 skill 目录运行: + +```powershell +python scripts/init_skill.py +``` + +`init_skill.py` 管理文档缓存,不负责执行业务登录。它会复用本地 `cache/`;只有核心文档或索引缺失、缓存超过 7 天,或显式传入 `--force-refresh` 时,才从线上 `document-index.txt` 定位并下载 `rqamsc` Markdown 源文档,然后刷新缓存并重建索引。 + +常用命令: +- 普通查文档:`python scripts/init_skill.py` +- 强制刷新文档:`python scripts/init_skill.py --force-refresh` +- 初始化后顺带展示环境摘要:`python scripts/init_skill.py --show-env` + +### 运行时初始化 + +- 在执行任何需要 Python SDK 的业务脚本前,先确认客户机器上有可用 Python,并确认目标 Python 环境已安装 `rqamsc`。如果确认 `rqamsc` 未安装,先提示用户在目标 Python 环境安装 `rqamsc`,不要继续执行业务脚本。 +- 不要假设客户机器一定有 `python` 命令;如果没有 Python,先提示用户安装或指定可用解释器。 +- 如果设置了 `RQAMSC_PYTHON`,优先检查该解释器环境;否则检查当前默认 Python。 +- 任何会真正执行 `rqamsc` 功能的脚本或代码,在进入业务逻辑前都必须先走固定运行时初始化入口,不要在各处重复手写 `rqamsc.init(...)` +- 固定运行时初始化入口为 `scripts/rqamsc_runtime.py` 中的 `initialize_rqamsc()` +- `scripts/inspect_env.py` 负责展示环境摘要,并复用上述固定初始化入口;它是检查脚本,不是让其他脚本复制粘贴初始化实现的模板 + +- Python 解释器优先级: + - 如果设置了 `RQAMSC_PYTHON`,优先使用该解释器 + - 否则使用当前默认 `python` +- 运行时凭据优先使用共享 profile: + - `rqamsc setup` 写入的 `rqams-cli` profile 是 CLI 和 Python SDK 的共同配置来源 + - Python 通过 `RQAMSC_PROFILE` 选择 profile + - 如果未设置 `RQAMSC_PROFILE`,则使用 `rqams-cli` 配置文件里的当前 active profile 或顶层默认配置 + - 如果设置了 `RQAMS_CLI_CONFIG`,Python 读取同一个配置文件路径 +- 环境变量只保留必要选择项: + - `RQAMSC_PROFILE` + - `RQAMS_CLI_CONFIG` + - `RQAMSC_SSL_VERIFY` + - `RQAMSC_PYTHON` +- 不要维护另一套账号密码环境变量;账号、密码、AMS 地址和 workspace 只从共享 profile 读取 +- 如果 profile 无法提供用户名、密码或 AMS 地址,提示用户先创建或修复共享 profile:agent 在本地临时目录生成登录 payload 模板文件,例如 `D:\tmp\rqams_setup_.json`,用户在文件中填写密码后,由 agent 执行 `rqamsc setup --payload @` +- 如果设置了 `RQAMSC_SSL_VERIFY`,优先使用该值 +- 如果未设置 `RQAMSC_SSL_VERIFY`: + - profile 中的 AMS 地址以 `https://` 开头时,默认 `ssl_verify=True` + - profile 中的 AMS 地址以 `http://` 开头时,默认 `ssl_verify=False` +- 如果 profile 中保存了 workspace,初始化后自动切换到该 workspace +- 初始化完成后,只简洁告知当前: + - Python 环境 + - 配置来源和 profile + - 登录账号 + - AMS 地址 + - 当前 workspace 名称 +- 同时提示用户:如需切换账号或 workspace,优先切换 `RQAMSC_PROFILE`;如需切换解释器,可设置 `RQAMSC_PYTHON` +- 不在初始化阶段主动展开版本影响说明 +- 业务功能文档不重复强调 workspace,默认以上述初始化结果为准 +- 环境摘要默认每个会话只向用户展示一次;只有当前会话第一次进入 `rqamsc` 任务、Python 环境变化、AMS 地址变化、workspace 变化、初始化失败,或用户明确要求查看当前环境时,才重复展示。 + +### 版本处理 + +- 默认优先兼容本地已安装的 `rqamsc` 版本 +- 当用户提出具体需求时,再按需检查该需求涉及的接口是否受版本变更影响 +- 如果文档路径与本地版本存在差异,应优先提示用户当前按本地版本处理 +- 只有在用户明确确认要升级后,才帮助用户升级 `rqamsc` +- 如需确认版本或接口可用性,直接检查目标 Python 环境中已安装的 `rqamsc` 包;如果设置了 `RQAMSC_PYTHON`,以该解释器环境为准。 +- Python API 字段、示例模板和默认值按当前 Python `rqamsc` 版本独立维护,不要求与 `rqams-cli` 命令字段或 CLI 产品创建模板一致。跨路径比较时只比较业务意图和结果语义,不用字段名强行对齐。 + +### 查找文档 + +1. 优先在 `cache/api_index/api_index.md` 中查找具体接口 +2. 如果接口索引不足以定位,再到 `cache/api_index/section_index.md` 中查找相关章节 +3. 根据索引中的 `line_range` 到 `cache/api_docs/api-rqamsc.md` 读取对应段落 +4. 只有索引无法定位时,才直接在 `cache/api_docs/api-rqamsc.md` 中搜索 + +索引文件顶部的 `Source` 行标明 `line_range` 对应的源文档文件。 + +以下源文档由 `scripts/init_skill.py` 按线上文档拆分生成到本地 `cache/`: +- `cache/api_docs/api-rqamsc.md` +- `cache/api_docs/changelogs.md` +- `cache/api_docs/manual-rqamsc.md` +- `cache/api_docs/tutorial-rqamsc.md` +- `cache/api_docs/rqamsc-faq.md` + +当前索引主题包括: +- `api_index.md` +- `section_index.md` +- `changelog_index.md` + +### 批量查询 + +如果 API 文档或函数签名显示接口支持批量输入,优先按批量接口查询。用户未指定批量大小时,默认每批 10 个标的;每批结果按原始输入顺序合并,并保留失败批次或失败标的的信息。批量失败且错误指向请求规模、字段格式或单个标的问题时,先缩小批量或逐项重试,再向用户报告不可恢复的失败项。 + +### 其他注意事项 + +- 优先使用公开 API 和 README 中已有工作流,不要猜测内部调用方式 +- 涉及写入操作时,先确认目标产品和输入字段 +- 涉及托管事件创建、更新、上传或删除时,先阅读上级 skill 的 `references/custodian_event_workflow.md`;申购、赎回事件写入前必须确认或补齐申赎开放日和 4 位申赎单位净值 +- 示例代码默认是接口模板,除非明确标注,否则不视为已完成端到端验证 +- 常见经验性问题参考: + - `references/pitfalls.md` diff --git a/.cursor/skills/rqams/rqamsc-python/references/pitfalls.md b/.cursor/skills/rqams/rqamsc-python/references/pitfalls.md new file mode 100644 index 000000000..fe540c17d --- /dev/null +++ b/.cursor/skills/rqams/rqamsc-python/references/pitfalls.md @@ -0,0 +1,22 @@ +# 常见问题 + +## 通用规则 + +- 业务参数由用户在当前任务中明确提供,不要通过扫描本机目录、历史脚本或文件名猜测业务对象 +- 名称类输入先解析,再执行写操作 +- 用户给出产品名、产品组名、workspace 名称时,先查询并确认唯一匹配对象 +- 一旦解析成功,后续调用优先使用对象 id +- 如果名称不唯一或匹配结果不明确,先要求用户确认 + +## Windows 特有问题 + +- 在 Windows 下遇到中文路径、空格路径时,先检查文件是否存在 +- 优先使用 PowerShell 的 `Get-Item -LiteralPath` 或 `Test-Path -LiteralPath` +- 不要优先用内联 Python 直接打开中文路径文件 +- 用户在命令行中输入中文产品名、空间名时,先查询对象列表确认名称是否被正确解析,再执行后续调用 + +## 版本处理 + +- 默认优先兼容本地已安装的 `rqamsc` 版本 +- 当具体需求涉及新接口时,再检查本地接口是否存在 +- 未经用户确认,不主动升级 `rqamsc` diff --git a/.cursor/skills/rqams/rqamsc-python/scripts/generate_indexes.py b/.cursor/skills/rqams/rqamsc-python/scripts/generate_indexes.py new file mode 100644 index 000000000..e843b95b5 --- /dev/null +++ b/.cursor/skills/rqams/rqamsc-python/scripts/generate_indexes.py @@ -0,0 +1,368 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Generate documentation indexes for rqamsc cached markdown sources. +""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path + + +HEADING_PATTERN = re.compile(r"^(#{1,6})\s+(.+?)\s*$") +API_SIGNATURE_PATTERN = re.compile(r"^\s*rqamsc\.([A-Za-z_][A-Za-z0-9_]*)\s*\(") +API_MANUAL_TITLE = "RQAMSC API 手册" + + +def read_source_lines(source_path: Path) -> list[str]: + """ + Read markdown source lines once for downstream index generation. + + :param source_path: Markdown source file path. + :return: Source lines with trailing newlines preserved. + :raises OSError: Raised when the source file cannot be read. + """ + + return source_path.read_text(encoding="utf-8").splitlines(keepends=True) + + +def extract_headings(lines: list[str]) -> list[tuple[int, int, str]]: + """ + Extract markdown headings outside fenced code blocks. + + :param lines: Source markdown lines. + :return: Tuples of heading line number, level, and title. + """ + + headings: list[tuple[int, int, str]] = [] + in_code_block = False + for idx, line in enumerate(lines, start=1): + stripped = line.rstrip("\n") + if stripped.strip().startswith("```"): + in_code_block = not in_code_block + continue + if in_code_block: + continue + match = HEADING_PATTERN.match(stripped) + if not match: + continue + level = len(match.group(1)) + title = match.group(2).strip() + headings.append((idx, level, title)) + return headings + + +def build_heading_ranges(headings: list[tuple[int, int, str]], total_lines: int) -> list[tuple[int, int, str, int]]: + """ + Build closed line ranges for headings. + + :param headings: Extracted heading tuples. + :param total_lines: Total line count in the source document. + :return: Tuples of heading start line, level, title, and end line. + """ + + ranges: list[tuple[int, int, str, int]] = [] + for index, (start, level, title) in enumerate(headings): + end = total_lines + for next_start, next_level, _next_title in headings[index + 1 :]: + if next_level <= level: + end = next_start - 1 + break + ranges.append((start, level, title, end)) + return ranges + + +def find_api_manual_range(heading_ranges: list[tuple[int, int, str, int]]) -> tuple[int, int] | None: + """ + Locate the formal API manual section in the markdown document. + + :param heading_ranges: Heading ranges for the source document. + :return: Inclusive start and end line numbers for the API manual section, or None. + """ + + for start, _level, title, end in heading_ranges: + if title == API_MANUAL_TITLE: + return start, end + return None + + +def extract_api_matches(lines: list[str], start_line: int = 1, end_line: int | None = None) -> list[tuple[str, int]]: + """ + Extract rqamsc API calls from fenced code blocks within a line range. + + :param lines: Source markdown lines. + :param start_line: Inclusive line number where scanning starts. + :param end_line: Inclusive line number where scanning ends. Defaults to the full document. + :return: Ordered tuples of API name and source line number. + """ + + matches: list[tuple[str, int]] = [] + in_code_block = False + inclusive_end_line = end_line or len(lines) + + for idx, line in enumerate(lines, start=1): + if idx < start_line: + continue + if idx > inclusive_end_line: + break + + stripped = line.rstrip("\n") + if stripped.strip().startswith("```"): + in_code_block = not in_code_block + continue + if not in_code_block: + continue + + match = API_SIGNATURE_PATTERN.match(stripped) + if match: + matches.append((match.group(1), idx)) + + return matches + + +def deduplicate_api_matches(matches: list[tuple[str, int]]) -> list[tuple[str, int]]: + """ + Keep the first occurrence of each API within ordered matches. + + :param matches: Ordered API matches. + :return: Deduplicated ordered API matches. + """ + + deduplicated: list[tuple[str, int]] = [] + seen_names: set[str] = set() + for api_name, line_number in matches: + if api_name in seen_names: + continue + seen_names.add(api_name) + deduplicated.append((api_name, line_number)) + return deduplicated + + +def map_section_apis( + heading_ranges: list[tuple[int, int, str, int]], + api_matches: list[tuple[str, int]], +) -> dict[int, list[str]]: + """ + Map headings to the APIs that appear inside their line ranges. + + :param heading_ranges: Heading ranges for the source document. + :param api_matches: API matches scoped to the formal API manual. + :return: Mapping from heading start line to ordered API names. + """ + + mapping: dict[int, list[str]] = {} + for start, _level, _title, end in heading_ranges: + section_apis: list[str] = [] + seen_names: set[str] = set() + for api_name, line_number in api_matches: + if start <= line_number <= end and api_name not in seen_names: + section_apis.append(api_name) + seen_names.add(api_name) + mapping[start] = section_apis + return mapping + + +def generate_section_index(source_path: Path, output_path: Path) -> None: + """ + Generate the section index for the cached markdown source. + + :param source_path: Markdown source file path. + :param output_path: Output section index file path. + :return: None. + :raises OSError: Raised when the source file cannot be read or the output cannot be written. + """ + + lines = read_source_lines(source_path) + headings = extract_headings(lines) + heading_ranges = build_heading_ranges(headings, len(lines)) + api_manual_range = find_api_manual_range(heading_ranges) + api_matches = [] + if api_manual_range is not None: + api_matches = deduplicate_api_matches(extract_api_matches(lines, *api_manual_range)) + else: + api_matches = deduplicate_api_matches(extract_api_matches(lines)) + section_api_mapping = map_section_apis(heading_ranges, api_matches) + + rows = [ + "# section_index", + "", + f"Source: `{source_path.name}`", + "", + "| Level | Title | line_range | apis |", + "| --- | --- | --- | --- |", + ] + for start, level, title, end in heading_ranges: + apis = ", ".join(f"`{api_name}`" for api_name in section_api_mapping.get(start, [])) or "-" + rows.append(f"| `{level}` | `{title}` | `{start}-{end}` | {apis} |") + + output_path.write_text("\n".join(rows) + "\n", encoding="utf-8") + + +def generate_changelog_index(source_path: Path, output_path: Path) -> None: + """ + Generate a compact changelog index from the cached markdown source. + + :param source_path: Markdown source file path. + :param output_path: Output changelog index file path. + :return: None. + :raises OSError: Raised when the source file cannot be read or the output cannot be written. + """ + + lines = source_path.read_text(encoding="utf-8").splitlines() + rows = [ + "# changelog_index", + "", + f"Source: `{source_path.name}`", + "", + "| Version | Date | Summary |", + "| --- | --- | --- |", + ] + capture = False + for line in lines: + if line.strip() == "## 更新履历": + capture = True + continue + if not capture and line.strip().startswith("| 0."): + capture = True + if not capture: + continue + if capture and line.startswith("# RQAMSC API 手册"): + break + if line.startswith("| 0."): + parts = [part.strip() for part in line.strip().strip("|").split("|")] + if len(parts) >= 3: + version = parts[0] + date = parts[1] + change_labels = ["新增", "改善", "不兼容改动"] + changes = [] + for label, value in zip(change_labels, parts[2:5]): + if value: + changes.append(f"**{label}**: {value}") + summary = "
    ".join(changes) if changes else parts[2] + rows.append(f"| `{version}` | `{date}` | {summary} |") + + output_path.write_text("\n".join(rows) + "\n", encoding="utf-8") + + +def generate_api_index(source_path: Path, output_path: Path) -> None: + """ + Generate a formal API index scoped to the API manual section only. + + :param source_path: Markdown source file path. + :param output_path: Output API index file path. + :return: None. + :raises OSError: Raised when the source file cannot be read or the output cannot be written. + """ + + lines = read_source_lines(source_path) + headings = extract_headings(lines) + heading_ranges = build_heading_ranges(headings, len(lines)) + api_manual_range = find_api_manual_range(heading_ranges) + rows = [ + "# api_index", + "", + f"Source: `{source_path.name}`", + "", + "| API | line_range |", + "| --- | --- |", + ] + + if api_manual_range is None: + matches = deduplicate_api_matches(extract_api_matches(lines)) + else: + matches = deduplicate_api_matches(extract_api_matches(lines, *api_manual_range)) + + for i, (api_name, start) in enumerate(matches): + end = len(lines) + if i + 1 < len(matches): + end = matches[i + 1][1] - 1 + rows.append(f"| `{api_name}` | `{start}-{end}` |") + + output_path.write_text("\n".join(rows) + "\n", encoding="utf-8") + + +def generate_all_indexes(source_path: Path, output_dir: Path, changelog_source_path: Path | None = None) -> None: + """ + Generate all cached documentation indexes. + + :param source_path: Markdown source file path. + :param output_dir: Output directory for generated indexes. + :param changelog_source_path: Optional changelog markdown source path. + :return: None. + :raises OSError: Raised when output directories cannot be created or files cannot be written. + """ + + output_dir.mkdir(parents=True, exist_ok=True) + generate_section_index(source_path, output_dir / "section_index.md") + generate_changelog_index(changelog_source_path or source_path, output_dir / "changelog_index.md") + generate_api_index(source_path, output_dir / "api_index.md") + + +def default_changelog_source_path() -> Path: + """ + Resolve the default cached changelog markdown source path. + + :return: Default cached changelog source path. + """ + + return Path(__file__).resolve().parent.parent / "cache" / "api_docs" / "changelogs.md" + + +def default_source_path() -> Path: + """ + Resolve the default cached markdown source path. + + :return: Default cached markdown source path. + """ + + return Path(__file__).resolve().parent.parent / "cache" / "api_docs" / "api-rqamsc.md" + + +def default_output_dir() -> Path: + """ + Resolve the default output directory for generated indexes. + + :return: Default cache index directory. + """ + + return Path(__file__).resolve().parent.parent / "cache" / "api_index" + + +def main() -> int: + """ + Parse command line arguments and generate all indexes. + + :return: Process exit code. + :raises OSError: Raised when source or output files cannot be accessed. + """ + + parser = argparse.ArgumentParser(description="Generate rqamsc markdown indexes") + parser.add_argument( + "--source", + type=Path, + default=default_source_path(), + help="Path to the cached markdown source file", + ) + parser.add_argument( + "--output-dir", + type=Path, + default=default_output_dir(), + help="Directory for generated index files", + ) + parser.add_argument( + "--changelog-source", + type=Path, + default=default_changelog_source_path(), + help="Path to the cached changelog markdown source file", + ) + args = parser.parse_args() + + generate_all_indexes(args.source, args.output_dir, args.changelog_source) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.cursor/skills/rqams/rqamsc-python/scripts/init_skill.py b/.cursor/skills/rqams/rqamsc-python/scripts/init_skill.py new file mode 100644 index 000000000..d0ead386b --- /dev/null +++ b/.cursor/skills/rqams/rqamsc-python/scripts/init_skill.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +rqamsc skill initialization. + +Refreshes cached online docs and regenerates lightweight indexes when cache files +are missing or older than DEFAULT_CACHE_DAYS. +""" + +from __future__ import annotations + +import argparse +import re +import sys +import time +from pathlib import Path +from urllib.parse import urlparse +from urllib.error import URLError +from urllib.request import urlopen + + +DEFAULT_CACHE_DAYS = 7 +DOCUMENT_INDEX_URL = "https://www.ricequant.com/doc/document-index.txt" +DEFAULT_DOC_URLS = { + "api-rqamsc.md": "https://www.ricequant.com/doc/sources/rqamsc/api-rqamsc.md", + "changelogs.md": "https://www.ricequant.com/doc/sources/rqamsc/changelogs.md", + "manual-rqamsc.md": "https://www.ricequant.com/doc/sources/rqamsc/manual-rqamsc.md", + "tutorial-rqamsc.md": "https://www.ricequant.com/doc/sources/rqamsc/tutorial-rqamsc.md", + "rqamsc-faq.md": "https://www.ricequant.com/doc/sources/rqamsc/rqamsc-faq.md", +} +DOC_LINK_PATTERN = re.compile(r"\[([^\]]+)\]\((https://www\.ricequant\.com/doc/sources/rqamsc/[^)#]+\.md)(?:#[^)]+)?\)") + + +def skill_root() -> Path: + return Path(__file__).resolve().parent.parent + + +def cache_docs_dir() -> Path: + return skill_root() / "cache" / "api_docs" + + +def cached_doc_path(filename: str) -> Path: + return cache_docs_dir() / filename + + +def api_doc_path() -> Path: + return cached_doc_path("api-rqamsc.md") + + +def changelog_doc_path() -> Path: + return cached_doc_path("changelogs.md") + + +def index_dir() -> Path: + return skill_root() / "cache" / "api_index" + + +def required_index_paths() -> list[Path]: + return [ + index_dir() / "api_index.md", + index_dir() / "section_index.md", + index_dir() / "changelog_index.md", + ] + + +def cache_expired(path: Path, cache_days: int = DEFAULT_CACHE_DAYS) -> bool: + if not path.exists(): + return True + max_age_seconds = cache_days * 24 * 60 * 60 + return time.time() - path.stat().st_mtime > max_age_seconds + + +def fetch_text(url: str, retries: int = 3, timeout_seconds: int = 60) -> str: + last_error: Exception | None = None + for attempt in range(1, retries + 1): + try: + with urlopen(url, timeout=timeout_seconds) as response: + return response.read().decode("utf-8") + except (OSError, TimeoutError, URLError) as exc: + last_error = exc + if attempt < retries: + time.sleep(attempt) + raise RuntimeError(f"Failed to download {url}") from last_error + + +def doc_filename(url: str) -> str: + return Path(urlparse(url).path).name + + +def discover_doc_urls() -> dict[str, str]: + """ + Discover current rqamsc source markdown URLs from the public document index. + + Falls back to known source URLs if the index format changes. + """ + + doc_urls = dict(DEFAULT_DOC_URLS) + try: + index_text = fetch_text(DOCUMENT_INDEX_URL) + except RuntimeError: + return doc_urls + + for _title, url in DOC_LINK_PATTERN.findall(index_text): + filename = doc_filename(url) + if filename: + doc_urls[filename] = url + return doc_urls + + +def download_docs() -> None: + cache_docs_dir().mkdir(parents=True, exist_ok=True) + for filename, url in discover_doc_urls().items(): + cached_doc_path(filename).write_text(fetch_text(url), encoding="utf-8") + + +def generate_indexes() -> None: + from generate_indexes import generate_all_indexes + + index_dir().mkdir(parents=True, exist_ok=True) + generate_all_indexes(api_doc_path(), index_dir(), changelog_doc_path()) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Initialize rqamsc skill cache") + parser.add_argument( + "--force-refresh", + action="store_true", + help="Force refresh cached docs and indexes", + ) + parser.add_argument( + "--show-env", + action="store_true", + help="Show concise environment summary after initialization", + ) + args = parser.parse_args() + + essential_docs = [api_doc_path(), changelog_doc_path()] + required_cache_files = essential_docs + required_index_paths() + needs_refresh = args.force_refresh or any(cache_expired(path) for path in required_cache_files) + + if needs_refresh: + print("[INFO] Refreshing rqamsc docs cache...") + download_docs() + print("[INFO] Regenerating indexes...") + generate_indexes() + + if args.show_env: + from inspect_env import main as inspect_env_main + + inspect_env_main() + + print("Done") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.cursor/skills/rqams/rqamsc-python/scripts/inspect_env.py b/.cursor/skills/rqams/rqamsc-python/scripts/inspect_env.py new file mode 100644 index 000000000..adfc242a8 --- /dev/null +++ b/.cursor/skills/rqams/rqamsc-python/scripts/inspect_env.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Inspect rqamsc runtime environment and print concise initialization context. +""" + +from __future__ import annotations + +from rqamsc_runtime import RuntimeConfigError, build_runtime_config, initialize_rqamsc + + +def main() -> int: + config = build_runtime_config() + print(f"Python: {config.python_executable}") + + try: + result = initialize_rqamsc(config) + print(f"Config source: {result.config_source}") + print(f"Profile: {result.profile or ''}") + print(f"Account: {result.username}") + print(f"AMS URI: {result.uri}") + print(f"Workspace: {result.workspace_name}") + return 0 + except RuntimeConfigError as exc: + print("RQAMSC config: incomplete") + print(f"Missing fields: {' / '.join(exc.missing_keys)}") + print("Fix: run rqamsc setup with a profile, or set RQAMSC_PROFILE to an existing profile") + return 0 + except Exception as exc: + print(f"RQAMSC init failed: {exc}") + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.cursor/skills/rqams/rqamsc-python/scripts/rqamsc_runtime.py b/.cursor/skills/rqams/rqamsc-python/scripts/rqamsc_runtime.py new file mode 100644 index 000000000..735294f3c --- /dev/null +++ b/.cursor/skills/rqams/rqamsc-python/scripts/rqamsc_runtime.py @@ -0,0 +1,276 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Shared rqamsc runtime initialization helpers for skill scripts. +""" + +from __future__ import annotations + +import os +import sys +import json +import platform +from dataclasses import dataclass +from pathlib import Path + +CLI_CONFIG_ENV = "RQAMS_CLI_CONFIG" +RQAMSC_PROFILE_ENV = "RQAMSC_PROFILE" + + +@dataclass(frozen=True) +class RuntimeConfig: + """ + Runtime configuration loaded from a shared profile. + + :param python_executable: Active Python interpreter path. + :param username: RQAMSC login username. + :param password: RQAMSC login password. + :param uri: AMS endpoint URI. + :param ssl_verify: Whether SSL certificate verification is enabled. + :param workspace: Optional workspace name to switch to after initialization. + :param config_source: Source used for auth fields. + :param profile: Optional CLI config profile used for auth fields. + """ + + python_executable: Path + username: str | None + password: str | None + uri: str | None + ssl_verify: bool + workspace: str | None + config_source: str = "environment" + profile: str | None = None + + +@dataclass(frozen=True) +class RuntimeInitResult: + """ + Result returned by rqamsc runtime initialization. + + :param python_executable: Active Python interpreter path. + :param username: RQAMSC login username. + :param uri: AMS endpoint URI. + :param workspace_name: Active workspace name after initialization. + :param config_source: Source used for auth fields. + :param profile: Shared rqams-cli profile used for auth fields. + """ + + python_executable: Path + username: str + uri: str + workspace_name: str + config_source: str + profile: str | None + + +class RuntimeConfigError(RuntimeError): + """ + Raised when required RQAMSC runtime fields are missing. + """ + + def __init__(self, missing_keys: list[str]) -> None: + """ + Initialize the configuration error with missing keys. + + :param missing_keys: Required runtime field names that are absent. + """ + + self.missing_keys = missing_keys + message = f"Missing required rqamsc config fields: {', '.join(missing_keys)}" + super().__init__(message) + + +def parse_ssl_verify(uri: str, raw_value: str | None) -> bool: + """ + Parse SSL verification behavior from environment variables. + + :param uri: AMS endpoint URI. + :param raw_value: Raw RQAMSC_SSL_VERIFY value from the environment. + :return: Parsed SSL verification flag. + :raises ValueError: Raised when the raw value cannot be interpreted. + """ + + if raw_value is not None and raw_value != "": + normalized = raw_value.strip().lower() + if normalized in {"1", "true", "yes", "y"}: + return True + if normalized in {"0", "false", "no", "n"}: + return False + raise ValueError(f"Unsupported RQAMSC_SSL_VERIFY value: {raw_value}") + return uri.startswith("https://") + + +def _string_or_none(value: object) -> str | None: + if not isinstance(value, str): + return None + value = value.strip() + return value or None + + +def _user_config_dir() -> Path: + override = os.getenv("XDG_CONFIG_HOME") + if override: + return Path(override) + system = platform.system() + if system == "Windows": + appdata = os.getenv("APPDATA") + if appdata: + return Path(appdata) + if system == "Darwin": + return Path.home() / "Library" / "Application Support" + return Path.home() / ".config" + + +def get_cli_config_path() -> Path: + """ + Return the rqams-cli config path, matching the CLI path rules. + + :return: Path to rqams-cli config.json. + """ + + override = os.getenv(CLI_CONFIG_ENV) + if override and override.strip(): + return Path(override) + return _user_config_dir() / "rqams-cli" / "config.json" + + +def _legacy_cli_config_path() -> Path: + return _user_config_dir() / "rqamsc-demo" / "config.json" + + +def load_cli_config() -> dict[str, object]: + """ + Load rqams-cli local configuration. + + Missing config files are treated as empty configuration so environment-only + setups continue to work. + """ + + path = get_cli_config_path() + if not path.exists(): + legacy_path = _legacy_cli_config_path() + if not legacy_path.exists(): + return {} + path = legacy_path + raw = path.read_text(encoding="utf-8-sig") + if not raw.strip(): + return {} + loaded = json.loads(raw) + if not isinstance(loaded, dict): + raise ValueError(f"rqams-cli config must be a JSON object: {path}") + return loaded + + +def select_cli_profile(config: dict[str, object], profile: str | None) -> dict[str, object]: + """ + Promote a selected rqams-cli profile to the active config shape. + """ + + selected_profile = profile or _string_or_none(config.get("profile")) + if not selected_profile: + return config + profiles = config.get("profiles") + if not isinstance(profiles, dict): + return config + selected = profiles.get(selected_profile) + if not isinstance(selected, dict): + return config + merged = dict(config) + merged.update(selected) + merged["profile"] = selected_profile + return merged + + +def build_runtime_config() -> RuntimeConfig: + """ + Load rqamsc runtime configuration. + + The runtime is profile-based: `rqamsc setup` writes credentials to the + rqams-cli config, and `RQAMSC_PROFILE` selects the same profile for Python + SDK initialization. Auth fields are not read from standalone environment + variables; the shared profile is the single source of truth. + + :return: Parsed runtime configuration. + :raises ValueError: Raised when SSL verification configuration is invalid. + """ + + profile = _string_or_none(os.getenv(RQAMSC_PROFILE_ENV)) + cli_config = select_cli_profile(load_cli_config(), profile) + profile = _string_or_none(cli_config.get("profile")) or profile + + username = _string_or_none(cli_config.get("username")) + password = _string_or_none(cli_config.get("password")) + uri = _string_or_none(cli_config.get("base_url")) + workspace = _string_or_none(cli_config.get("workspace_id")) + ssl_verify_raw = os.getenv("RQAMSC_SSL_VERIFY") + ssl_verify = parse_ssl_verify(uri or "", ssl_verify_raw) + + return RuntimeConfig( + python_executable=Path(sys.executable), + username=username, + password=password, + uri=uri, + ssl_verify=ssl_verify, + workspace=workspace, + config_source="rqams-cli config", + profile=profile, + ) + + +def get_missing_required_keys(config: RuntimeConfig) -> list[str]: + """ + Compute required environment keys that are missing from configuration. + + :param config: Runtime configuration to validate. + :return: Missing required key names. + """ + + missing_keys: list[str] = [] + if not config.username: + missing_keys.append("username") + if not config.password: + missing_keys.append("password") + if not config.uri: + missing_keys.append("base_url") + return missing_keys + + +def initialize_rqamsc(config: RuntimeConfig | None = None) -> RuntimeInitResult: + """ + Initialize rqamsc and switch to the configured workspace when provided. + + :param config: Optional prebuilt runtime configuration. + :return: Structured initialization result. + :raises RuntimeConfigError: Raised when required environment variables are missing. + :raises Exception: Propagates rqamsc import and initialization failures. + """ + + runtime_config = config or build_runtime_config() + missing_keys = get_missing_required_keys(runtime_config) + if missing_keys: + raise RuntimeConfigError(missing_keys) + + import rqamsc + + rqamsc.init( + username=runtime_config.username, + password=runtime_config.password, + uri=runtime_config.uri, + ssl_verify=runtime_config.ssl_verify, + ) + + workspace_name = "" + if runtime_config.workspace: + rqamsc.choose_workspace(runtime_config.workspace) + current_workspace = rqamsc.current_workspace() + if current_workspace is not None: + workspace_name = getattr(current_workspace, "name", None) or str(current_workspace) + + return RuntimeInitResult( + python_executable=runtime_config.python_executable, + username=runtime_config.username or "", + uri=runtime_config.uri or "", + workspace_name=workspace_name, + config_source=runtime_config.config_source, + profile=runtime_config.profile, + ) diff --git a/.cursor/skills/rqdata-python/SKILL.md b/.cursor/skills/rqdata-python/SKILL.md new file mode 100644 index 000000000..4f118107e --- /dev/null +++ b/.cursor/skills/rqdata-python/SKILL.md @@ -0,0 +1,47 @@ +--- +name: rqdata-python +description: "RQData数据API使用指南。当需要查询RQData数据接口、获取金融数据时使用。支持A股、港股、期货、期权、指数、基金、可转债等市场数据查询,包含HTTP API和Python API文档。" +--- + +# rqdata-python + +每次skill使用前,从仓库根目录执行`python .cursor/skills/rqdata-python/scripts/init_skill.py`。如果返回RQData license不可用,则提示用户`需正确安装rqsdk,配置许可证,或者问题应联系RQData技术支持获得帮助`,终止skill的使用 + +## 使用方法 + +### 查找API接口 + +1. 确定所需API文档:在`cache/api_doc_index.md`中grep需要的API文档,示例:Grep `宏观|GDP` on `cache/api_doc_index.md`的结果显示满足GDP宏观数据查询需求的API文档是`macro-economy.md` + +2. 确定所需API接口:在`cache/api_index/{API文档名}_index.md`中grep所需API接口。API索引文件中每行表一个API接口的API Name、Description、Line Range,确定匹配API接口的行号List Range,阅读API接口开始的50行来获取API接口定义,如果50行不够多阅读更多行。示例:Grep `宏观|GDP` on `cache/api_index/macro-economy_index.md`的结果显示满足GDP宏观数据查询需求的API接口是`econ.get_factors`,行号范围是87-131,阅读`cache/api_index/macro-economy.md`的第87到87+50行获得API定义 + - 注意也许需要调用多个API接口来满足需求,所以可能需要定位多个API接口 + +3. 若以上步骤没有定位到API接口,才尝试在`cache`中搜索 + +### 应使用真实资产代码 + +- 如果API参数涉及到资产代码(例如股票代码,期货代码,期权代码等),**强制**获取真实的资产代码: + - 推断资产类型,资产名称(或资产代码),市场名称 + - 如果是查询期权合约代码请参考`references/options_contract_query.md` + - 如果是查询期货合约代码请参考`references/futures_contract_query.md` + - 如果是查询其他类型资产代码参考`references/common_asset_code_query.md` + +### 其他注意事项 + +- 如果API参数涉及到宏观因子名称,查询宏观因子名称参考`cache/api_docs/macro_factor_names.csv` +- 调用RQData API前必须调用`rqdatac.init()`来初始化 +- **禁止**阅读`scripts`中的源代码 +- 当遇到使用问题的时候,参考`references/pitfall.md`了解常见错误使用陷阱 + +## Skil执行示例 + +用户prompt:`请为我展示近几年的中国的存款准备金率` + +Agent执行步骤: + +1. 强制执行skill初始化脚本 +2. `存款准备金率`是宏观数据,根据api_doc_index.md,应在`macro-economy.md`中查找API +3. 使用`macro-economy_index.md`快速定位满足需求的API接口为`econ.get_reserve_ratio`,行范围87-131 +4. 使用read工具读取`macro-economy.md`中读取接口头50行(第87到87+50行) +5. 从read工具返回中获取API定义和参数信息 +6. 让我开始编写代码 diff --git a/.cursor/skills/rqdata-python/references/common_asset_code_query.md b/.cursor/skills/rqdata-python/references/common_asset_code_query.md new file mode 100644 index 000000000..d8b6a0506 --- /dev/null +++ b/.cursor/skills/rqdata-python/references/common_asset_code_query.md @@ -0,0 +1,11 @@ +# 一般资产合约代码获取指南 + +分两步执行: + +1. 查看命令行帮助获取使用方法:`python .cursor/skills/rqdata-python/scripts/code_index_manager.py --help` + +2. 执行获取脚本获取资产代码 + +```bash +python .cursor/skills/rqdata-python/scripts/code_index_manager.py --query "贵州茅台" --market cn --type CS +``` diff --git a/.cursor/skills/rqdata-python/references/futures_contract_query.md b/.cursor/skills/rqdata-python/references/futures_contract_query.md new file mode 100644 index 000000000..f00bfbbaf --- /dev/null +++ b/.cursor/skills/rqdata-python/references/futures_contract_query.md @@ -0,0 +1,9 @@ +# 期货contract获取指南 + +分三步执行: + +1. 推断期货品种(underlying symbol),例如沪深300期货为'IF',铜期货为'CU'等 + +2. 使用获取到的期货品种(underlying symbol)调用期货API获取期货合约 + - 可获取可交易合约列表 + - 可获取主力合约 diff --git a/.cursor/skills/rqdata-python/references/options_contract_query.md b/.cursor/skills/rqdata-python/references/options_contract_query.md new file mode 100644 index 000000000..13e8c8860 --- /dev/null +++ b/.cursor/skills/rqdata-python/references/options_contract_query.md @@ -0,0 +1,18 @@ + +# 期权contract获取指南 + +分三步执行: + +1. 推断期权类型 + - 期货期权 + - ETF期权 + - 个股期权 + - 其他类型期权 + +2. 获取期权标的(underlying) + - 期货期权:underlyiny就是期货品种,例如铜期权的underlying是`CU` + - ETF期权:使用命令行获取underlying,例如50ETF使用命令行`python ~/.claude/skills/rqdata-python/scripts/code_index_manager.py -q "50ETF" -m cn -t ETF`获取underlying + - 个股期权:和ETF期权的underlying获取方法一致 + - 其他类型期权:自行推断如何获取 + +3. 使用获取到的期权标的(underlying)调用期权API获取期权合约 diff --git a/.cursor/skills/rqdata-python/references/pitfall.md b/.cursor/skills/rqdata-python/references/pitfall.md new file mode 100644 index 000000000..907c44ed1 --- /dev/null +++ b/.cursor/skills/rqdata-python/references/pitfall.md @@ -0,0 +1,17 @@ +# RQData API常见错误使用陷阱(以及正确使用方式) + +## 1. 未验证合约代码是否符合 Ricequant 规范 + +```python +df = rqdatac.get_price('600000', start_date='20230101', end_date='20230110') +``` + +## 2. 交易日历未使用RQData API + +错误代码: + +```python +trading_dates = rqdatac.get_trading_calendar('SSE', start_date='2025-12-01', end_date='2025-12-31') +``` + +正确方式:查阅合约查询相关API文档,发现应该使用`get_trading_dates`API \ No newline at end of file diff --git a/.cursor/skills/rqdata-python/scripts/api_index_manager.py b/.cursor/skills/rqdata-python/scripts/api_index_manager.py new file mode 100644 index 000000000..966b91108 --- /dev/null +++ b/.cursor/skills/rqdata-python/scripts/api_index_manager.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +""" +API 索引管理器 - 快速定位 API 在文档中的位置 +""" + +from pathlib import Path +from typing import Dict, List, Optional +import re +import logging + +# 配置日志(INFO级别) +logging.basicConfig( + level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" +) +logger = logging.getLogger("APIIndexManager") + + +class APIIndexManager: + """API 索引管理器""" + + def __init__(self, api_index_dir: Optional[str] = None): + """ + 初始化 API 索引管理器 + + Args: + api_index_dir: api_index 目录路径 + """ + if api_index_dir is None: + skill_root = Path(__file__).parent.parent + self.api_index_dir = skill_root / "cache" / "api_index" + else: + self.api_index_dir = Path(api_index_dir) + + logger.info( + f"API Index Manager initialized with directory: {self.api_index_dir}" + ) + + # 内存缓存:{document_name: {api_name: line_info}} + self.memory_cache: Dict[str, Dict[str, dict]] = {} + + def _parse_api_index_file(self, document_name: str) -> Dict[str, dict]: + """解析 api_index 文件,构建 API 到行号的映射""" + logger.info(f"Parsing API index for document: {document_name}") + + # 将文档名转换为索引文件名 + index_file_name = f"{document_name.replace('.md', '')}_index.md" + index_file_path = self.api_index_dir / index_file_name + + if not index_file_path.exists(): + error_msg = f"API index file not found: {index_file_path}" + logger.error(error_msg) + raise FileNotFoundError( + f"{error_msg}\nPlease run init_skill.py to generate API indices." + ) + + # 读取索引文件 + try: + with open(index_file_path, "r", encoding="utf-8") as f: + content = f.read() + except Exception as e: + error_msg = f"Error reading index file {index_file_path}: {e}" + logger.error(error_msg) + raise IOError(error_msg) + + # 解析表格中的 API 信息 + api_mapping = {} + table_pattern = re.compile(r"\|\s*`([^`]+)`\s*\|\s*([^\|]+)\|\s*(\d+)\s*\|") + + for line in content.splitlines(): + match = table_pattern.match(line) + if match: + api_name = match.group(1).strip() + description = match.group(2).strip() + line_number = int(match.group(3).strip()) + + api_mapping[api_name] = { + "line_number": line_number, + "description": description, + "document_name": document_name, + } + + logger.info(f"Parsed {len(api_mapping)} APIs from {document_name}") + return api_mapping + + def get_api_location(self, api_name: str, document_name: str) -> dict: + """获取 API 在文档中的位置(单个 API)""" + logger.info( + f"Getting location for API '{api_name}' in document '{document_name}'" + ) + + # 检查内存缓存 + if document_name not in self.memory_cache: + self.memory_cache[document_name] = self._parse_api_index_file(document_name) + + doc_cache = self.memory_cache[document_name] + + # 查找 API + if api_name in doc_cache: + api_info = doc_cache[api_name] + logger.info(f"Found API '{api_name}' at line {api_info['line_number']}") + return api_info + else: + # 获取可用 API 列表(前 10 个) + available_apis = list(doc_cache.keys())[:10] + error_msg = ( + f"API '{api_name}' not found in document index '{document_name}'.\n" + f"Available APIs in this document: {available_apis}...\n" + f"Please check the API name or use full document search." + ) + logger.error(error_msg) + raise ValueError(error_msg) + + def get_batch_api_locations( + self, api_names: List[str], document_name: str + ) -> Dict[str, dict]: + """批量获取多个 API 的位置""" + logger.info( + f"Batch getting locations for {len(api_names)} APIs in document '{document_name}'" + ) + + # 确保文档索引已加载 + if document_name not in self.memory_cache: + self.memory_cache[document_name] = self._parse_api_index_file(document_name) + + doc_cache = self.memory_cache[document_name] + result = {} + missing_apis = [] + + for api_name in api_names: + if api_name in doc_cache: + result[api_name] = doc_cache[api_name] + else: + missing_apis.append(api_name) + + if missing_apis: + available_apis = list(doc_cache.keys())[:10] + error_msg = ( + f"APIs not found in document index '{document_name}': {missing_apis}\n" + f"Available APIs in this document: {available_apis}...\n" + f"Please check the API names or use full document search." + ) + logger.error(error_msg) + raise ValueError(error_msg) + + logger.info(f"Successfully found {len(result)} APIs") + return result + + def list_apis(self, document_name: str) -> List[str]: + """列出指定文档中的所有 API""" + logger.info(f"Listing APIs for document '{document_name}'") + + if document_name not in self.memory_cache: + self.memory_cache[document_name] = self._parse_api_index_file(document_name) + + apis = list(self.memory_cache[document_name].keys()) + logger.info(f"Found {len(apis)} APIs in document '{document_name}'") + return apis + + def clear_cache(self, document_name: Optional[str] = None): + """清除内存缓存""" + if document_name: + logger.info(f"Clearing cache for document '{document_name}'") + self.memory_cache.pop(document_name, None) + else: + logger.info("Clearing all cache") + self.memory_cache.clear() + + +if __name__ == "__main__": + # 测试代码 + manager = APIIndexManager() + + try: + # 测试单个 API 查找 + location = manager.get_api_location("get_price", "generic-api.md") + print(f"API 'get_price' found at line {location['line_number']}") + print(f"Description: {location['description']}") + + # 测试批量查找 + batch_locations = manager.get_batch_api_locations( + ["get_price", "get_ticks", "current_snapshot"], "generic-api.md" + ) + print(f"\nBatch lookup found {len(batch_locations)} APIs") + for api_name, info in batch_locations.items(): + print(f" {api_name}: line {info['line_number']}") + + # 测试列出所有 API + apis = manager.list_apis("generic-api.md") + print(f"\nTotal APIs in generic-api.md: {len(apis)}") + + except Exception as e: + print(f"Error: {e}") diff --git a/.cursor/skills/rqdata-python/scripts/cache_manager.py b/.cursor/skills/rqdata-python/scripts/cache_manager.py new file mode 100644 index 000000000..469ea69e6 --- /dev/null +++ b/.cursor/skills/rqdata-python/scripts/cache_manager.py @@ -0,0 +1,626 @@ +#!/usr/bin/env python3 +""" +RQData文档缓存管理器 +提供缓存机制,优化文档访问速度 +""" + +import io +import re +import subprocess +import time +from pathlib import Path +from typing import Optional +from urllib.parse import unquote +import logging + +# 配置日志(INFO级别) +logging.basicConfig( + level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" +) +logger = logging.getLogger("RQDataCacheManager") + + +INDEX_URL = "https://www.ricequant.com/doc/document-index.txt" +DEFAULT_CACHE_DAYS = 7 + + +class RQDataCacheManager: + """RQData文档缓存管理器""" + + def __init__(self, cache_dir: Optional[str] = None): + """ + 初始化缓存管理器 + + Args: + cache_dir: 缓存目录路径,默认为skill的cache/api_docs目录 + """ + if cache_dir is None: + skill_root = Path(__file__).parent.parent + self.cache_dir = skill_root / "cache" / "api_docs" + else: + self.cache_dir = Path(cache_dir) + + self.cache_dir.mkdir(parents=True, exist_ok=True) + + def _extract_filename_from_url(self, url: str) -> str: + """从URL提取文件名""" + filename = url.split("/")[-1] + if not filename: + raise ValueError(f"URL does not contain a filename: {url}") + + clean_filename = filename.split("?")[0].split("#")[0] + decoded_filename = unquote(clean_filename) + + if not re.match(r"^[a-zA-Z0-9\-_.]+$", decoded_filename): + raise ValueError( + f"Invalid filename contains special characters: {decoded_filename}" + ) + + return decoded_filename + + def _is_cache_expired( + self, cache_path: Path, max_age_days: int = DEFAULT_CACHE_DAYS + ) -> bool: + """检查缓存文件是否过期(基于文件修改时间)""" + if not cache_path.exists(): + return True + + file_mtime = cache_path.stat().st_mtime + file_age = time.time() - file_mtime + max_age_seconds = max_age_days * 24 * 60 * 60 + + return file_age > max_age_seconds + + def _get_cache_path(self, url: str) -> Path: + """根据URL生成缓存文件路径,使用真实文件名""" + decoded_filename = self._extract_filename_from_url(url) + cache_path = self.cache_dir / decoded_filename + if cache_path.exists(): + raise FileExistsError(f"Cache file already exists: {cache_path}") + return cache_path + + def _get_cache_path_for_read(self, url: str) -> Optional[Path]: + """获取缓存文件路径(用于读取,不检查冲突)""" + try: + decoded_filename = self._extract_filename_from_url(url) + except ValueError: + return None + + cache_path = self.cache_dir / decoded_filename + if not cache_path.exists(): + return None + + return cache_path + + def get_cached_content(self, url: str) -> Optional[str]: + """ + 获取缓存的文档内容 + + Args: + url: 文档URL + + Returns: + 缓存的文档内容,如果缓存不存在则返回None + """ + cache_path = self._get_cache_path_for_read(url) + if cache_path is None: + return None + + try: + with open(cache_path, "r", encoding="utf-8") as f: + return f.read() + except IOError: + return None + + def save_to_cache( + self, url: str, content: str, allow_overwrite: bool = False + ) -> None: + """ + 保存文档内容到缓存 + + Args: + url: 文档URL + content: 文档内容 + allow_overwrite: 是否允许覆盖现有文件 + """ + decoded_filename = self._extract_filename_from_url(url) + cache_path = self.cache_dir / decoded_filename + + if cache_path.exists(): + if not allow_overwrite: + raise FileExistsError(f"Cache file already exists: {cache_path}") + cache_path.unlink() + + with open(cache_path, "w", encoding="utf-8") as f: + f.write(content) + + def fetch_document(self, url: str, timeout: int = 60, retries: int = 3) -> str: + """ + 使用curl获取文档内容,支持重试 + + Args: + url: 文档URL + timeout: 超时时间(秒),默认60秒 + retries: 重试次数,默认3次 + + Returns: + 文档内容 + + Raises: + RuntimeError: 如果获取失败 + """ + last_error = None + + for attempt in range(retries): + try: + result = subprocess.run( + ["curl", "-s", "-L", "--max-time", str(timeout), url], + capture_output=True, + timeout=timeout + 5, + encoding="utf-8", + errors="replace", + ) + + if ( + result.returncode == 0 + and result.stdout + and len(result.stdout) > 100 + ): + return result.stdout + else: + last_error = ( + f"curl failed with code {result.returncode} or empty response" + ) + if attempt < retries - 1: + continue + raise RuntimeError(f"Failed to fetch document: {url}") + + except subprocess.TimeoutExpired: + last_error = f"Timeout after {timeout}s" + if attempt < retries - 1: + continue + raise RuntimeError(f"Timeout fetching document: {url}") + except Exception as e: + last_error = str(e) + if attempt < retries - 1: + continue + raise RuntimeError(f"Error fetching document: {url}, {str(e)}") + + raise RuntimeError(f"Failed after {retries} attempts: {last_error}") + + def _parse_index_content(self, content: str) -> list[str]: + """解析索引文档内容,提取URL列表""" + import re + + urls = [] + url_pattern = re.compile( + r"https://www\.ricequant\.com/doc/sources/rqdata/python/[^\)]+\.md" + ) + + for line in content.splitlines(): + matches = url_pattern.findall(line) + for url in matches: + if url not in urls: + urls.append(url) + + return urls + + def fetch_document_index(self, max_age_days: int = DEFAULT_CACHE_DAYS) -> list[str]: + """ + 获取文档索引列表(带过期检查) + + Args: + max_age_days: 索引缓存的最大天数 + + Returns: + URL列表 + + Raises: + RuntimeError: 如果获取失败且没有可用缓存 + """ + cache_path = self._get_cache_path_for_read(INDEX_URL) + + if cache_path and not self._is_cache_expired(cache_path, max_age_days): + try: + with open(cache_path, "r", encoding="utf-8") as f: + index_content = f.read() + return self._parse_index_content(index_content) + except IOError: + pass + + try: + index_content = self.fetch_document(INDEX_URL) + self.save_to_cache(INDEX_URL, index_content, allow_overwrite=True) + return self._parse_index_content(index_content) + except RuntimeError as e: + if cache_path and cache_path.exists(): + try: + with open(cache_path, "r", encoding="utf-8") as f: + index_content = f.read() + print(f"Warning: Using expired index cache due to fetch error: {e}") + return self._parse_index_content(index_content) + except IOError: + pass + raise + + def _build_doc_name_map(self, max_age_days: int = DEFAULT_CACHE_DAYS) -> dict: + """构建文档名到URL的映射(从索引动态生成)""" + urls = self.fetch_document_index(max_age_days) + mapping = {} + for url in urls: + filename = url.split("/")[-1] + if filename: + mapping[filename] = url + return mapping + + def get_document( + self, + url: str, + force_refresh: bool = False, + max_age_days: int = DEFAULT_CACHE_DAYS, + ) -> str: + """ + 获取文档内容(优先使用缓存,带过期检查) + + Args: + url: 文档URL + force_refresh: 是否强制刷新缓存 + max_age_days: 缓存的最大天数 + + Returns: + 文档内容 + """ + cache_path = self._get_cache_path_for_read(url) + + if ( + not force_refresh + and cache_path + and not self._is_cache_expired(cache_path, max_age_days) + ): + try: + with open(cache_path, "r", encoding="utf-8") as f: + return f.read() + except IOError: + pass + + try: + content = self.fetch_document(url) + self.save_to_cache(url, content, allow_overwrite=True) + return content + except RuntimeError as e: + if cache_path and cache_path.exists(): + try: + with open(cache_path, "r", encoding="utf-8") as f: + content = f.read() + print(f"Warning: Using cached version due to fetch error: {e}") + return content + except IOError: + pass + raise + + def get_document_by_name( + self, + doc_name: str, + force_refresh: bool = False, + max_age_days: int = DEFAULT_CACHE_DAYS, + ) -> str: + """ + 根据文档名获取文档内容(带过期检查) + + Args: + doc_name: 文档名(如 "stock-mod.md") + force_refresh: 是否强制刷新缓存 + max_age_days: 缓存的最大天数 + + Returns: + 文档内容 + """ + doc_map = self._build_doc_name_map(max_age_days) + + if doc_name not in doc_map: + available = list(doc_map.keys()) + raise ValueError( + f"Unknown document: {doc_name}\n" + f"Available documents: {', '.join(available)}" + ) + + url = doc_map[doc_name] + return self.get_document(url, force_refresh, max_age_days) + + def list_documents(self, max_age_days: int = DEFAULT_CACHE_DAYS) -> list[str]: + """ + 获取所有可用文档名称列表 + + Args: + max_age_days: 索引缓存的最大天数 + + Returns: + 文档名称列表 + """ + doc_map = self._build_doc_name_map(max_age_days) + return list(doc_map.keys()) + + def clear_cache(self, url: Optional[str] = None) -> int: + """ + 清理缓存 + + Args: + url: 如果指定,只清理该URL的缓存;否则清理所有缓存 + + Returns: + 清理的文件数量 + """ + if url: + cache_path = self._get_cache_path_for_read(url) + if cache_path is not None and cache_path.exists(): + cache_path.unlink() + return 1 + return 0 + else: + count = 0 + for cache_file in self.cache_dir.glob("*"): + if cache_file.is_file(): + cache_file.unlink() + count += 1 + return count + + def clear_all_cache(self) -> int: + """ + 清理所有缓存文件 + + Returns: + 清理的文件数量 + """ + return self.clear_cache() + + def get_cache_info(self) -> dict: + """ + 获取缓存统计信息 + + Returns: + 缓存统计信息字典 + """ + cache_files = [f for f in self.cache_dir.glob("*") if f.is_file()] + total_count = len(cache_files) + total_size = 0 + + for cache_file in cache_files: + try: + file_size = cache_file.stat().st_size + total_size += file_size + except IOError: + continue + + return { + "cache_dir": str(self.cache_dir), + "total_count": total_count, + "total_size_bytes": total_size, + "total_size_mb": round(total_size / 1024 / 1024, 2), + } + + def read_document_lines( + self, document_name: str, start_line: int, end_line: Optional[int] = None + ) -> str: + """读取文档的特定行范围""" + logger.info( + f"Reading lines {start_line}-{end_line or start_line} from document '{document_name}'" + ) + + cache_path = self.cache_dir / document_name + + if not cache_path.exists(): + error_msg = f"Document not found in cache: {document_name}" + logger.error(error_msg) + raise FileNotFoundError(f"{error_msg}\nCache directory: {self.cache_dir}") + + try: + with open(cache_path, "r", encoding="utf-8") as f: + lines = f.readlines() + except Exception as e: + error_msg = f"Error reading document '{document_name}': {e}" + logger.error(error_msg) + raise IOError(error_msg) + + start_idx = max(0, start_line - 1) + end_idx = end_line if end_line else start_line + end_idx = min(len(lines), end_idx) + + selected_lines = lines[start_idx:end_idx] + content = "".join(selected_lines) + + logger.info(f"Extracted {len(selected_lines)} lines") + return content + + def get_api_definition( + self, + api_name: str, + document_name: str, + context_lines: int = 100, + api_index_manager: Optional[object] = None, + ) -> dict: + """获取 API 定义(使用 api_index 优化)""" + logger.info( + f"Getting API definition for '{api_name}' in document '{document_name}'" + ) + + if api_index_manager is None: + from api_index_manager import APIIndexManager + + api_index_manager = APIIndexManager() + + try: + # type: ignore - APIIndexManager 会在运行时正确导入 + api_info = api_index_manager.get_api_location(api_name, document_name) # type: ignore + line_number = api_info["line_number"] + logger.info(f"API '{api_name}' found at line {line_number}") + except (ValueError, FileNotFoundError) as e: + logger.error(f"Failed to get API location: {e}") + raise + + start_line = max(1, line_number - context_lines // 2) + end_line = line_number + context_lines // 2 + + try: + content = self.read_document_lines(document_name, start_line, end_line) + logger.info( + f"Successfully read {len(content)} characters for API '{api_name}'" + ) + except (FileNotFoundError, IOError) as e: + logger.error(f"Failed to read document lines: {e}") + raise + + return { + "api_name": api_name, + "document_name": document_name, + "line_number": line_number, + "description": api_info.get("description", ""), + "content": content, + "context_range": (start_line, end_line), + } + + def find_factor_download_link( + self, doc_content: str, api_start_line: int = 0 + ) -> Optional[str]: + """从API文档的特定API部分查找宏观因子xlsx下载链接""" + lines = doc_content.split("\n") + search_start = api_start_line if api_start_line > 0 else 0 + + for i, line in enumerate(lines[search_start:], search_start): + if ("factors" in line.lower() or "宏观因子" in line) and ".xlsx" in line: + match = re.search(r'https://[^\s"\'<>]+\.xlsx', line) + if match: + url = match.group(0) + logger.info(f"Found factor download link at line {i + 1}: {url}") + return url + + logger.warning("No factor download link found in document") + return None + + def fetch_binary_file( + self, url: str, output_name: str, timeout: int = 60, retries: int = 3 + ) -> Path: + """下载二进制文件(xlsx等)""" + output_path = self.cache_dir / output_name + + last_error = None + for attempt in range(retries): + try: + result = subprocess.run( + [ + "curl", + "-s", + "-L", + "--max-time", + str(timeout), + "-o", + str(output_path), + url, + ], + capture_output=True, + timeout=timeout + 5, + encoding="utf-8", + errors="replace", + ) + + if ( + result.returncode == 0 + and output_path.exists() + and output_path.stat().st_size > 0 + ): + logger.info( + f"Successfully downloaded {output_name} ({output_path.stat().st_size} bytes)" + ) + return output_path + else: + last_error = f"curl failed with code {result.returncode}" + if output_path.exists(): + output_path.unlink() + if attempt < retries - 1: + continue + + except subprocess.TimeoutExpired: + last_error = f"Timeout after {timeout}s" + if output_path.exists(): + output_path.unlink() + if attempt < retries - 1: + continue + except Exception as e: + last_error = str(e) + if output_path.exists(): + output_path.unlink() + if attempt < retries - 1: + continue + + raise RuntimeError( + f"Failed to download binary file after {retries} attempts: {last_error}" + ) + + def convert_xlsx_to_csv( + self, xlsx_path: Path, csv_name: Optional[str] = None + ) -> Path: + """将xlsx文件转换为CSV""" + if not xlsx_path.exists(): + raise FileNotFoundError(f"Xlsx file not found: {xlsx_path}") + + if csv_name is None: + csv_name = xlsx_path.stem + ".csv" + csv_path = self.cache_dir / csv_name + + try: + import pandas as pd + + df = pd.read_excel(xlsx_path, engine="openpyxl") + df.to_csv(csv_path, index=False, encoding="utf-8-sig") + logger.info( + f"Converted {xlsx_path.name} to {csv_name} ({csv_path.stat().st_size} bytes)" + ) + return csv_path + + except ImportError: + raise ImportError("pandas and openpyxl are required for xlsx conversion") + except Exception as e: + raise RuntimeError(f"Failed to convert xlsx to csv: {e}") + + def download_and_convert_factor_file( + self, doc_name: str = "macro-economy.md" + ) -> Optional[Path]: + """下载并转换宏观因子名称文件""" + cache_path = self.cache_dir / doc_name + + if not cache_path.exists(): + logger.warning(f"Document not found: {doc_name}") + return None + + with open(cache_path, "r", encoding="utf-8") as f: + content = f.read() + + link = self.find_factor_download_link(content, api_start_line=87) + if not link: + logger.warning("No factor download link found in macro-economy.md") + return None + + xlsx_path = self.fetch_binary_file(link, "macro_factor_names.xlsx") + csv_path = self.convert_xlsx_to_csv(xlsx_path, "macro_factor_names.csv") + + if xlsx_path.exists(): + xlsx_path.unlink() + logger.info(f"Removed temporary xlsx file: {xlsx_path.name}") + + return csv_path + + +if __name__ == "__main__": + cache_mgr = RQDataCacheManager() + + test_url = "https://www.ricequant.com/doc/sources/rqdata/python/stock-mod.md" + + content = cache_mgr.get_document(test_url) + print(f"Document length: {len(content)}") + + info = cache_mgr.get_cache_info() + print(f"\nCache Info:") + print(f" Total files: {info['total_count']}") + print(f" Total size: {info['total_size_mb']} MB") + + cleared = cache_mgr.clear_cache() + print(f"\nCleared {cleared} cache files") diff --git a/.cursor/skills/rqdata-python/scripts/check_rqdata_license.py b/.cursor/skills/rqdata-python/scripts/check_rqdata_license.py new file mode 100644 index 000000000..1db7ccb52 --- /dev/null +++ b/.cursor/skills/rqdata-python/scripts/check_rqdata_license.py @@ -0,0 +1,9 @@ +import rqdatac + +"""检查 RQData License 是否有效""" +try: + rqdatac.init() + print("✓ RQData License 有效") +except rqdatac.RQDataError: + print("❌ RQData License 未激活或已过期") + print("申请试用: https://www.ricequant.com/welcome/trial/rqsdk-cloud") \ No newline at end of file diff --git a/.cursor/skills/rqdata-python/scripts/code_index_manager.py b/.cursor/skills/rqdata-python/scripts/code_index_manager.py new file mode 100644 index 000000000..9ca1fc59e --- /dev/null +++ b/.cursor/skills/rqdata-python/scripts/code_index_manager.py @@ -0,0 +1,343 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +资产代码索引管理器 +提供股票代码搜索功能,支持通过代码或名称模糊匹配查找股票 +""" + +import time +from pathlib import Path +from typing import Optional + +INDEX_CACHE_DAYS = 1 + + +class CodeIndexManager: + """资产代码索引管理器""" + + def __init__(self, cache_dir: Optional[str] = None): + """ + 初始化索引管理器 + + Args: + cache_dir: 缓存目录路径,默认为 skill 的 cache/code_index 目录 + """ + if cache_dir is None: + skill_root = Path(__file__).parent.parent + self.cache_dir = skill_root / "cache" / "code_index" + else: + self.cache_dir = Path(cache_dir) + + self.cache_dir.mkdir(parents=True, exist_ok=True) + + def _get_index_path(self, market: str, asset_type: str = "CS") -> Path: + """获取索引文件路径""" + filename = f"{market}_{asset_type.lower()}_code_index.md" + return self.cache_dir / filename + + def _is_index_expired(self, index_path: Path) -> bool: + """检查索引是否过期""" + if not index_path.exists(): + return True + + file_mtime = index_path.stat().st_mtime + file_age = time.time() - file_mtime + max_age_seconds = INDEX_CACHE_DAYS * 24 * 60 * 60 + + return file_age > max_age_seconds + + def build_index(self, market: str = "cn", asset_type: str = "CS") -> bool: + """ + 构建资产代码索引 + + Args: + market: 市场代码,'cn' - A股,'hk' - 港股 + asset_type: 资产类型,'CS' - 股票 + + Returns: + 是否成功构建索引 + """ + import rqdatac + + index_path = self._get_index_path(market, asset_type) + + try: + df = rqdatac.all_instruments(type=asset_type, market=market) + + if df is None or df.empty: + print(f"[WARN] No data returned for {market}/{asset_type}") + return False + + rows = [] + for _, row in df.iterrows(): + order_book_id = row.get("order_book_id", "") + symbol = row.get("symbol", "") + abbrev_symbol = row.get("abbrev_symbol", "") + + if order_book_id: + rows.append( + { + "order_book_id": order_book_id, + "symbol": symbol, + "abbrev_symbol": abbrev_symbol, + } + ) + + lines = [ + "| order_book_id | symbol | abbrev_symbol |", + "|---------------|--------|----------------|", + ] + + for r in rows: + symbol_escaped = r["symbol"].replace("|", "\\|") + abbrev_escaped = ( + r["abbrev_symbol"].replace("|", "\\|") if r["abbrev_symbol"] else "" + ) + lines.append( + f"| {r['order_book_id']} | {symbol_escaped} | {abbrev_escaped} |" + ) + + content = "\n".join(lines) + + with open(index_path, "w", encoding="utf-8") as f: + f.write(content) + + return True + + except Exception as e: + print(f"[FAIL] Failed to build index: {e}") + return False + + def get_index( + self, market: str = "cn", asset_type: str = "CS", force_refresh: bool = False + ) -> Optional[list[dict]]: + """ + 获取资产代码索引 + + Args: + market: 市场代码 + asset_type: 资产类型 + force_refresh: 是否强制刷新索引 + + Returns: + 索引数据列表,每个元素为 {'order_book_id', 'symbol', 'abbrev_symbol'} + """ + index_path = self._get_index_path(market, asset_type) + + if force_refresh or self._is_index_expired(index_path): + if not self.build_index(market, asset_type): + return None + + return self._parse_index(index_path) + + def _parse_index(self, index_path: Path) -> Optional[list[dict]]: + """解析索引文件""" + if not index_path.exists(): + return None + + try: + with open(index_path, "r", encoding="utf-8") as f: + lines = f.readlines() + + records = [] + in_table = False + + for line in lines: + line = line.strip() + + if line.startswith("| order_book_id"): + in_table = True + continue + + if in_table and line.startswith("|"): + if line == "|---|---|---|" or not line.strip(): + continue + + parts = [p.strip() for p in line.split("|")] + if len(parts) >= 4: + order_book_id = parts[1] + symbol = parts[2] + abbrev_symbol = parts[3] if len(parts) > 3 else "" + + if order_book_id and order_book_id != "order_book_id": + records.append( + { + "order_book_id": order_book_id, + "symbol": symbol, + "abbrev_symbol": abbrev_symbol, + } + ) + + return records + + except Exception as e: + print(f"[FAIL] Failed to parse index: {e}") + return None + + def search( + self, query: str, market: str = "cn", limit: int = 10, asset_type: str = "CS" + ) -> list[dict]: + """ + 搜索资产代码 + + Args: + query: 查询字符串(代码或名称) + market: 市场代码 + limit: 返回结果数量限制 + asset_type: 资产类型 + + Returns: + 匹配结果列表,每个元素为 {'order_book_id', 'symbol', 'abbrev_symbol', 'match_type'} + """ + records = self.get_index(market, asset_type) + + if not records: + return [] + + query = query.strip() + if not query: + return [] + + results = [] + query_lower = query.lower() + + for r in records: + match_type = None + + order_book_id = r["order_book_id"] + symbol = r["symbol"] + abbrev_symbol = r.get("abbrev_symbol", "") + + code_without_suffix = ( + order_book_id.split(".")[0] if "." in order_book_id else order_book_id + ) + + if order_book_id.lower() == query_lower: + match_type = "code_exact" + elif symbol.lower() == query_lower: + match_type = "name_exact" + elif code_without_suffix == query: + match_type = "code_prefix" + elif query_lower in symbol.lower(): + match_type = "name_contains" + elif abbrev_symbol and query_lower == abbrev_symbol.lower(): + match_type = "abbrev_exact" + elif abbrev_symbol and query_lower in abbrev_symbol.lower(): + match_type = "abbrev_contains" + + if match_type: + results.append( + { + "order_book_id": order_book_id, + "symbol": symbol, + "abbrev_symbol": abbrev_symbol, + "match_type": match_type, + } + ) + + results.sort( + key=lambda x: ( + 0 if x["match_type"] == "code_exact" else 1, + 0 if x["match_type"] == "name_exact" else 1, + 0 if x["match_type"] == "code_prefix" else 1, + 0 if x["match_type"] == "abbrev_exact" else 1, + 0 if x["match_type"] == "name_contains" else 1, + 0 if x["match_type"] == "abbrev_contains" else 1, + ) + ) + + return results[:limit] + + +def resolve_stock_code( + query: str, market: str = "cn", limit: int = 10, asset_type: str = "CS" +) -> list[dict]: + """ + 通过股票代码或公司名称查询真实股票代码 + + 支持输入: + - 纯数字: 600519, 000001 + - 带后缀: 600519.SH, 000001.SZ + - 公司名称: 贵州茅台, 智谱AI + + Args: + query: 查询字符串(代码或名称) + market: 市场代码,'cn' - A股,'hk' - 港股 + limit: 返回结果数量限制 + asset_type: 资产类型,默认 'CS' (股票) + + Returns: + 匹配结果列表,每个元素为: + { + 'order_book_id': '600519.XSHG', + 'symbol': '贵州茅台', + 'abbrev_symbol': 'GZMT', + 'match_type': 'name_exact' + } + """ + manager = CodeIndexManager() + return manager.search(query, market, limit, asset_type) + + +if __name__ == "__main__": + import argparse + import rqdatac + + parser = argparse.ArgumentParser( + description="资产代码搜索工具", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +允许的市场代码: + cn A股 (中国内地市场) + hk 港股 (香港市场) + +允许的资产类型: + CS 股票 (Common Stock) + ETF 交易所交易基金 + Future 期货 + Option 期权 + Convertible 可转债 + INDX 指数 + LOF 上市型开放式基金 + FUND 基金 + +使用示例: + python code_index_manager.py -q "贵州茅台" -m cn -t CS + python code_index_manager.py --query "600519" --market cn + python code_index_manager.py --query "腾讯" --market hk --type CS + """, + ) + parser.add_argument("--query", "-q", required=True, help="查询字符串(代码或名称)") + parser.add_argument( + "--market", + "-m", + default="cn", + choices=["cn", "hk"], + help="市场代码: cn (A股), hk (港股) [default: cn]", + ) + parser.add_argument( + "--type", + "-t", + default="CS", + help="资产类型: CS(股票), ETF, Future, Option, Convertible, INDX [default: CS]", + ) + parser.add_argument( + "--limit", + "-l", + type=int, + default=10, + help="返回结果数量 [default: 10]", + ) + + args = parser.parse_args() + + rqdatac.init() + + manager = CodeIndexManager() + results = manager.search(args.query, args.market, args.limit, args.type) + + if results: + for r in results: + print(f"{r['order_book_id']} | {r['symbol']} | {r['match_type']}") + else: + print("未找到匹配结果") diff --git a/.cursor/skills/rqdata-python/scripts/document_index_converter.py b/.cursor/skills/rqdata-python/scripts/document_index_converter.py new file mode 100644 index 000000000..9c21502ae --- /dev/null +++ b/.cursor/skills/rqdata-python/scripts/document_index_converter.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +""" +RQData文档索引获取脚本 +从官方获取文档索引并保存为Markdown文件 +""" + +import requests +from pathlib import Path +from datetime import datetime +from typing import Optional + + +class DocumentIndexFetcher: + """文档索引获取器""" + + def __init__(self): + self.index_url = "https://www.ricequant.com/doc/document-index.txt" + + def fetch(self, timeout: int = 60) -> str: + """获取文档索引内容""" + response = requests.get(self.index_url, timeout=timeout) + response.raise_for_status() + return response.text + + def save(self, content: str, output_path: Optional[Path] = None) -> Path: + """保存内容到文件""" + if output_path is None: + skill_root = Path(__file__).parent.parent + output_path = skill_root / "cache" / "document_index.md" + + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(content, encoding="utf-8") + return output_path + + def run(self, output_path: Optional[Path] = None) -> Path: + """执行获取并保存""" + print(f"正在获取文档索引: {self.index_url}") + content = self.fetch() + print(f"获取成功,内容长度: {len(content)} 字符") + + saved_path = self.save(content, output_path) + print(f"已保存到: {saved_path}") + return saved_path + + +def main(): + import argparse + + parser = argparse.ArgumentParser(description="RQData文档索引获取脚本") + parser.add_argument("-o", "--output", type=str, help="输出文件路径") + args = parser.parse_args() + + output_path = Path(args.output) if args.output else None + fetcher = DocumentIndexFetcher() + + try: + saved_path = fetcher.run(output_path) + print(f"\n成功: {saved_path}") + return 0 + except Exception as e: + print(f"\n错误: {e}") + return 1 + + +if __name__ == "__main__": + import sys + + sys.exit(main()) diff --git a/.cursor/skills/rqdata-python/scripts/generate_api_index.py b/.cursor/skills/rqdata-python/scripts/generate_api_index.py new file mode 100644 index 000000000..1e09b62c7 --- /dev/null +++ b/.cursor/skills/rqdata-python/scripts/generate_api_index.py @@ -0,0 +1,477 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +RQData API Index Generator + +Scans all markdown documentation files in the cache/api_docs directory and generates +individual API index files for each source file. +""" + +import re +import sys +from pathlib import Path +from typing import List, Dict, Optional + + +class APIIndexGenerator: + """Generates API index files from markdown documentation""" + + def __init__( + self, api_docs_dir: Optional[Path] = None, output_dir: Optional[Path] = None + ): + """ + Initialize the generator + + Args: + api_docs_dir: Directory containing source markdown files + output_dir: Directory to output index files + """ + if api_docs_dir is None: + # Default to cache/api_docs relative to script location + script_dir = Path(__file__).parent + skill_dir = script_dir.parent + self.api_docs_dir = skill_dir / "cache" / "api_docs" + else: + self.api_docs_dir = Path(api_docs_dir) + + if output_dir is None: + # Default to cache/api_index relative to script location + skill_dir = Path(__file__).parent.parent + self.output_dir = skill_dir / "cache" / "api_index" + else: + self.output_dir = Path(output_dir) + + # Patterns to exclude (non-API entries) + self.exclude_patterns = [ + re.compile(r"^[A-Z]+$"), # All caps (API, FAQ, etc.) + re.compile(r"^\d+$"), # Pure numbers + re.compile(r"^[\u4e00-\u9fff]+$"), # Pure Chinese + ] + + # Regex to match all ## headings (both top-level and API) + self.heading_pattern = re.compile(r"^#{2,3}\s+(.+?)\s*(\{#.+})?$") + + # Regex to match API headings with function name and description + # Format: ## or ### function_name - description {#xxx-API-anchor} + # Matches any anchor with -API- in it (rqdata-API, stock-API, etc.) + self.api_pattern = re.compile( + r"^#{2,3}\s+([\w\.]+)\s*[-–]\s*(.+?)\s*\{#[^}]+-API-[^}]+\}\s*$" + ) + + self.max_paragraphs = ( + 5 # Maximum paragraphs to extract for detailed description + ) + + def _is_valid_api(self, api_name: str) -> bool: + """ + Check if the name is a valid API function + + Args: + api_name: The API name to check + + Returns: + True if valid API, False otherwise + """ + # Must contain underscore or dot (module.function or function_name) + # OR be a valid alphanumeric name (at least 2 chars, starting with letter) + if "_" not in api_name and "." not in api_name: + # Check if it's a valid alphanumeric name (e.g., instruments, get_price) + if not re.match(r"^[a-zA-Z][a-zA-Z0-9]+$", api_name): + return False + + # Check against exclude patterns + for pattern in self.exclude_patterns: + if pattern.match(api_name): + return False + + return True + + def _extract_detailed_description( + self, lines: List[str], start_line: int, end_line: int + ) -> str: + """ + Extract detailed description from lines between API header and next section. + + Args: + lines: All lines from the file + start_line: Line number after the API header (0-indexed) + end_line: Line number before the next API header (0-indexed) + + Returns: + Detailed description as a string, or empty string if not found + """ + paragraphs = [] + prev_was_empty = False + table_line_count = 0 + in_code_block = False + + for i in range(start_line, min(end_line, len(lines))): + line = lines[i].rstrip("\n") + + # Handle code blocks + if line.strip().startswith("```"): + in_code_block = not in_code_block + continue + + if in_code_block: + continue + + # Check for table (two consecutive lines starting with |) + if line.strip().startswith("|"): + table_line_count += 1 + if table_line_count >= 2: + break # Stop at table + continue + + # Reset table counter if line doesn't start with | + if not line.strip().startswith("|"): + table_line_count = 0 + + # Check for new API header + if line.strip().startswith("##"): + break + + # Collect paragraphs (non-empty lines) + if line.strip(): + if not prev_was_empty or not paragraphs: + if not paragraphs: + paragraphs.append(line.strip()) + else: + paragraphs[-1] += " " + line.strip() + else: + paragraphs.append(line.strip()) + prev_was_empty = False + else: + prev_was_empty = True + # Stop if we have enough paragraphs + if len(paragraphs) >= self.max_paragraphs: + break + + return "
    ".join(paragraphs) if paragraphs else "" + + def extract_apis_from_file(self, file_path: Path) -> List[Dict]: + """ + Extract API definitions from a markdown file + + Args: + file_path: Path to the markdown file + + Returns: + List of API dictionaries with name, description, line_number, end_line_number + """ + apis = [] + + try: + with open(file_path, "r", encoding="utf-8") as f: + lines = f.readlines() + + total_lines = len(lines) + api_positions = [] + + for line_num, line in enumerate(lines): + line = line.strip() + + match = self.api_pattern.match(line) + if match: + api_name = match.group(1) + description = match.group(2).strip() + + if self._is_valid_api(api_name): + api_positions.append( + { + "api_name": api_name, + "description": description, + "line_number": line_num + 1, + } + ) + + for i, api in enumerate(api_positions): + if i + 1 < len(api_positions): + api["end_line_number"] = api_positions[i + 1]["line_number"] - 1 + else: + api["end_line_number"] = total_lines + + # Extract detailed description + start_idx = api["line_number"] # 0-indexed, line_number is 1-indexed + end_idx = api["end_line_number"] + + detailed_desc = self._extract_detailed_description( + lines, start_idx, end_idx + ) + api["detailed_description"] = detailed_desc + + apis.append(api) + + except Exception as e: + print(f" Warning: Error processing {file_path.name}: {e}") + + return apis + + def _extract_titles_from_file(self, file_path: Path) -> List[str]: + """ + Extract all titles from a markdown file. + + Args: + file_path: Path to the markdown file + + Returns: + List of title strings (top-level headings and API descriptions without function names) + """ + titles = [] + + try: + with open(file_path, "r", encoding="utf-8") as f: + lines = f.readlines() + + for line in lines: + line = line.strip() + + # Check if it's a heading + match = self.heading_pattern.match(line) + if not match: + continue + + heading_text = match.group(1).strip() + + # Check if it's an API heading (contains function name) + api_match = self.api_pattern.match(line) + if api_match: + # Extract only the description part (after the dash) + description = api_match.group(2).strip() + titles.append(description) + else: + # It's a top-level heading, use as-is + titles.append(heading_text) + + except Exception as e: + print(f" Warning: Error extracting titles from {file_path.name}: {e}") + + return titles + + def generate_doc_index(self) -> Optional[Path]: + """ + Generate api_doc_index.md - a combined index of all API documents. + + Returns: + Path to the generated file, or None if failed + """ + exclude_files = {"changelogs.md", "manual.md"} + + output_file = self.api_docs_dir.parent / "api_doc_index.md" + + source_files = [ + f for f in self.api_docs_dir.glob("*.md") if f.name not in exclude_files + ] + + if not source_files: + print(f"Error: No markdown files found in {self.api_docs_dir}") + return None + + # Check if regeneration is needed based on source file freshness + if output_file.exists(): + output_mtime = output_file.stat().st_mtime + # Only regenerate if any source file is newer than output + if all(f.stat().st_mtime <= output_mtime for f in source_files): + print(f"Skipped: {output_file.name} (up to date)") + return output_file + + md_files = sorted(self.api_docs_dir.glob("*.md")) + + doc_descriptions = [] + + for md_file in md_files: + if md_file.name in exclude_files: + continue + + titles = self._extract_titles_from_file(md_file) + if titles: + description = "。".join(titles) + doc_descriptions.append( + {"filename": md_file.name, "description": description} + ) + + if not doc_descriptions: + print("Warning: No document descriptions found") + return None + + with open(output_file, "w", encoding="utf-8") as f: + f.write("# API Doc Index\n\n") + f.write("| Document | Description |\n") + f.write("|----------|-------------|\n") + + for doc in doc_descriptions: + escaped_desc = doc["description"].replace("|", "\\|") + f.write(f"| {doc['filename']} | {escaped_desc} |\n") + + print(f"Generated: {output_file.name}") + return output_file + + def generate_index_file( + self, source_file: Path, apis: List[Dict] + ) -> Optional[Path]: + """ + Generate an index file for a source markdown file + + Args: + source_file: Path to the source markdown file + apis: List of API dictionaries + + Returns: + Path to the generated index file, or None if no APIs found + """ + if not apis: + print(f" No APIs found in {source_file.name}, skipping...") + return None + + # Sort by line number + apis.sort(key=lambda x: x["line_number"]) + + # Create output file path + source_name = source_file.stem # filename without extension + output_file = self.output_dir / f"{source_name}_index.md" + + # Check freshness: skip if output exists and source hasn't changed + if output_file.exists(): + output_mtime = output_file.stat().st_mtime + source_mtime = source_file.stat().st_mtime + if source_mtime <= output_mtime: + print(f" Skipped: {output_file.name} (up to date)") + return None + + # Ensure output directory exists + self.output_dir.mkdir(parents=True, exist_ok=True) + + # Write markdown content + with open(output_file, "w", encoding="utf-8") as f: + # Header + f.write(f"# API Index for {source_file.name}\n\n") + + # Summary + f.write("## Summary\n\n") + f.write(f"- Source File: {source_file.name}\n") + f.write(f"- Total APIs: {len(apis)}\n\n") + + # API Definitions + f.write("## API Definitions (Sorted by Line Number)\n\n") + f.write("| API Name | Description | Line Range |\n") + f.write("|----------|-------------|------------|\n") + + for api in apis: + # Build description: combine short description and detailed description + short_desc = api["description"] + detailed_desc = api.get("detailed_description", "") + + if detailed_desc: + full_description = f"{short_desc}
    {detailed_desc}" + else: + full_description = short_desc + + # Escape pipe characters in description + full_description = full_description.replace("|", "\\|") + # Replace newlines with
    + full_description = full_description.replace("\n", "
    ") + + line_range = f"{api['line_number']}-{api['end_line_number']}" + f.write( + f"| `{api['api_name']}` | {full_description} | {line_range} |\n" + ) + + f.write("\n") + + return output_file + + def run(self) -> int: + """ + Run the index generator + + Returns: + Number of index files generated + """ + print(f"API docs directory: {self.api_docs_dir}") + print(f"Output directory: {self.output_dir}") + print() + + # Check if api_docs directory exists + if not self.api_docs_dir.exists(): + print(f"Error: API docs directory not found: {self.api_docs_dir}") + return 0 + + # Find all markdown files + md_files = sorted(self.api_docs_dir.glob("*.md")) + + if not md_files: + print(f"Error: No markdown files found in {self.api_docs_dir}") + return 0 + + print(f"Found {len(md_files)} markdown files\n") + + # Files to exclude from API index generation + exclude_from_api_index = {"changelogs.md", "manual.md", "api_doc_index.md"} + + # Process each file + index_count = 0 + total_apis = 0 + + for md_file in md_files: + if md_file.name in exclude_from_api_index: + print(f"Skipping: {md_file.name} (excluded)") + continue + + print(f"Processing: {md_file.name}") + + try: + # Extract APIs from file + apis = self.extract_apis_from_file(md_file) + + if apis: + # Generate index file + output_file = self.generate_index_file(md_file, apis) + if output_file: + print(f" Generated: {output_file.name} ({len(apis)} APIs)") + index_count += 1 + total_apis += len(apis) + else: + print(f" No APIs found, skipping...") + + except Exception as e: + print(f" Error: {e}") + continue + + print() + print("=" * 50) + print(f"Summary:") + print(f" - Index files generated: {index_count}") + print(f" - Total APIs indexed: {total_apis}") + print(f" - Output directory: {self.output_dir}") + print("=" * 50) + print() + + print("Generating API doc index...") + doc_index_file = self.generate_doc_index() + if doc_index_file: + print(f" Success: {doc_index_file.name}") + else: + print(" Warning: Failed to generate API doc index") + + return index_count + + +def main(): + """Main entry point""" + print("RQData API Index Generator") + print("=" * 50) + print() + + generator = APIIndexGenerator() + index_count = generator.run() + + if index_count > 0: + print(f"\nSuccess! Generated {index_count} index file(s).") + return 0 + else: + print("\nNo index files generated.") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.cursor/skills/rqdata-python/scripts/init_cache.py b/.cursor/skills/rqdata-python/scripts/init_cache.py new file mode 100644 index 000000000..a47f674b6 --- /dev/null +++ b/.cursor/skills/rqdata-python/scripts/init_cache.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +RQData文档批量缓存初始化脚本 +首次运行时缓存所有RQData Python API文档 + +Usage: + python init_cache.py [--force-refresh] +""" + +import sys +import io +import os +import argparse + +os.environ["PYTHONIOENCODING"] = "utf-8" +sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace") +sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace") + +from pathlib import Path +from cache_manager import RQDataCacheManager + + +def main(): + """批量缓存所有RQData文档""" + parser = argparse.ArgumentParser(description="RQData文档缓存初始化") + parser.add_argument( + "--force-refresh", + action="store_true", + help="强制重新下载所有文档(忽略缓存)", + ) + args = parser.parse_args() + + force_refresh = args.force_refresh + + print("=" * 80) + print("RQData文档批量缓存初始化") + if force_refresh: + print("[强制刷新模式]") + print("=" * 80) + print() + + cache_mgr = RQDataCacheManager() + + info = cache_mgr.get_cache_info() + print(f"缓存目录: {info['cache_dir']}") + print(f"当前缓存文件数: {info['total_count']}") + print(f"缓存大小: {info['total_size_mb']} MB") + print() + + print("获取文档索引...") + try: + urls = cache_mgr.fetch_document_index() + print(f"找到 {len(urls)} 个文档") + except RuntimeError as e: + print(f"[X] 无法获取文档索引: {e}") + return 1 + + print() + print(f"准备缓存 {len(urls)} 个文档...") + print() + + success_count = 0 + skip_count = 0 + fail_count = 0 + + for i, url in enumerate(urls, 1): + doc_name = url.split("/")[-1] + print(f"[{i}/{len(urls)}] {doc_name}...", end=" ") + + try: + content = cache_mgr.get_document(url, force_refresh=force_refresh) + success_count += 1 + + except RuntimeError as e: + cache_path = cache_mgr._get_cache_path_for_read(url) + if cache_path and cache_path.exists(): + print(f"[WARN] 使用缓存版本") + skip_count += 1 + else: + print(f"[FAIL] {str(e)}") + fail_count += 1 + + print() + print("=" * 80) + print("缓存完成") + print("=" * 80) + print(f"成功下载: {success_count}") + print(f"使用缓存: {skip_count}") + print(f"失败: {fail_count}") + print() + + final_info = cache_mgr.get_cache_info() + print(f"最终缓存文件数: {final_info['total_count']}") + print(f"总缓存大小: {final_info['total_size_mb']} MB") + print() + + if force_refresh: + print("=" * 80) + print("强制刷新模式:下载宏观因子名称文件...") + print("=" * 80) + else: + print("=" * 80) + print("下载宏观因子名称文件...") + print("=" * 80) + try: + csv_path = cache_mgr.download_and_convert_factor_file("macro-economy.md") + if csv_path and csv_path.exists(): + print(f"[OK] 宏观因子名称已保存到: {csv_path.name}") + else: + print("[WARN] 宏观因子名称文件下载失败") + except Exception as e: + print(f"[WARN] 宏观因子名称文件处理异常: {e}") + print() + + if fail_count > 0 and success_count + skip_count == 0: + print("[WARN] 所有文档缓存失败,请检查网络连接后重试") + return 1 + elif fail_count > 0: + print("[WARN] 部分文档缓存失败,请检查网络连接后重试") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.cursor/skills/rqdata-python/scripts/init_skill.py b/.cursor/skills/rqdata-python/scripts/init_skill.py new file mode 100644 index 000000000..e6a8243e8 --- /dev/null +++ b/.cursor/skills/rqdata-python/scripts/init_skill.py @@ -0,0 +1,268 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +RQData Skill Initialization Script + +Performs all necessary setup before using the RQData skill: +1. Verify RQData license +2. Check if cache needs refresh (any api_doc expired) +3. Initialize document cache (only if needed) +4. Generate API indices (only if cache was refreshed) +5. Generate macro factor file (only if cache was refreshed) + +Exit codes: +- 0: Success +- 1: License check failed +- 2: Cache initialization failed +- 3: Both failed +""" + +import sys +import subprocess +import os +import time +from pathlib import Path +from typing import Optional + +DEFAULT_CACHE_DAYS = 7 + + +def print_header(): + """Print script header""" + pass # Suppressed for clean output on success + + +def check_license(): + """Check RQData license by calling rqdatac.init()""" + try: + import rqdatac + + rqdatac.init() + return True + except ImportError: + print("[FAIL] rqdatac not installed", file=sys.stderr) + return False + except Exception as e: + print(f"[FAIL] RQData license invalid: {e}", file=sys.stderr) + return False + + +def check_api_docs_expired(cache_dir: Optional[Path] = None) -> bool: + """Check if any api_docs file is expired (older than DEFAULT_CACHE_DAYS)""" + if cache_dir is None: + script_dir = os.path.dirname(os.path.abspath(__file__)) + skill_root = Path(script_dir).parent + cache_dir = skill_root / "cache" / "api_docs" + + if not cache_dir.exists(): + return True + + max_age_seconds = DEFAULT_CACHE_DAYS * 24 * 60 * 60 + current_time = time.time() + + md_files = list(cache_dir.glob("*.md")) + if not md_files: + return True + + for md_file in md_files: + file_age = current_time - md_file.stat().st_mtime + if file_age > max_age_seconds: + return True + + return False + + +def run_cache_init(force_refresh: bool = False): + """Run cache initialization (calls init_cache.py)""" + script_dir = os.path.dirname(os.path.abspath(__file__)) + init_cache_path = os.path.join(script_dir, "init_cache.py") + + if not os.path.exists(init_cache_path): + print(f"[FAIL] init_cache.py not found at: {init_cache_path}", file=sys.stderr) + return False + + args = [sys.executable, init_cache_path] + if force_refresh: + args.append("--force-refresh") + + try: + result = subprocess.run( + args, + capture_output=True, + timeout=300, # 5 minutes timeout when refreshing all + encoding="utf-8", + errors="replace", + ) + + if result.returncode == 0: + return True + else: + print("[FAIL] Cache initialization failed", file=sys.stderr) + if result.stdout: + print("Output:", file=sys.stderr) + print(result.stdout, file=sys.stderr) + if result.stderr: + print("Errors:", file=sys.stderr) + print(result.stderr, file=sys.stderr) + return False + + except subprocess.TimeoutExpired: + print("[FAIL] Cache initialization timed out", file=sys.stderr) + return False + except Exception as e: + print(f"[FAIL] Cache initialization error: {e}", file=sys.stderr) + return False + + +def generate_api_indices(): + """生成 API 索引文件""" + + script_dir = os.path.dirname(os.path.abspath(__file__)) + generate_script = os.path.join(script_dir, "generate_api_index.py") + + if not os.path.exists(generate_script): + error_msg = f"generate_api_index.py not found at: {generate_script}" + print(f"[FAIL] {error_msg}", file=sys.stderr) + return False + + try: + result = subprocess.run( + [sys.executable, generate_script], + capture_output=True, + timeout=180, + encoding="utf-8", + errors="replace", + ) + + if result.returncode == 0: + return True + else: + error_msg = "API indices generation failed" + print(f"[FAIL] {error_msg}", file=sys.stderr) + if result.stdout: + print("Output:", file=sys.stderr) + print(result.stdout, file=sys.stderr) + if result.stderr: + print("Errors:", file=sys.stderr) + print(result.stderr, file=sys.stderr) + return False + + except subprocess.TimeoutExpired: + error_msg = "API indices generation timed out" + print(f"[FAIL] {error_msg}", file=sys.stderr) + return False + except Exception as e: + error_msg = f"API indices generation error: {e}" + print(f"[FAIL] {error_msg}", file=sys.stderr) + return False + + +def build_code_indices(): + """构建资产代码索引""" + + script_dir = os.path.dirname(os.path.abspath(__file__)) + code_index_script = os.path.join(script_dir, "code_index_manager.py") + + if not os.path.exists(code_index_script): + print(f"[WARN] code_index_manager.py not found, skipping code index build") + return True + + try: + from code_index_manager import CodeIndexManager + + manager = CodeIndexManager() + + cn_ok = manager.build_index("cn", "CS") + if not cn_ok: + print("[WARN] CN stock code index build failed") + + return True + + except Exception as e: + print(f"[WARN] Code index build error: {e}", file=sys.stderr) + return True + + +def refresh_macro_factor_file(): + """刷新宏观因子名称文件""" + script_dir = os.path.dirname(os.path.abspath(__file__)) + init_cache_path = os.path.join(script_dir, "init_cache.py") + + if not os.path.exists(init_cache_path): + print(f"[WARN] init_cache.py not found, skipping macro factor file refresh") + return True + + try: + from cache_manager import RQDataCacheManager + + cache_mgr = RQDataCacheManager() + csv_path = cache_mgr.download_and_convert_factor_file("macro-economy.md") + if csv_path and csv_path.exists(): + print(f"[OK] Macro factor names refreshed: {csv_path.name}") + return True + else: + print("[WARN] Macro factor names file download failed") + return False + except Exception as e: + print(f"[WARN] Macro factor file refresh error: {e}", file=sys.stderr) + return False + + +def main(): + """Main function""" + print_header() + + license_ok = check_license() + print(file=sys.stderr) + + if not license_ok: + print(file=sys.stderr) + print("[FAIL] License check failed - skill cannot be used", file=sys.stderr) + return 1 + + docs_expired = check_api_docs_expired() + if docs_expired: + print("[INFO] API docs expired or missing, refreshing all...") + cache_ok = run_cache_init(force_refresh=True) + else: + cache_ok = True + print(file=sys.stderr) + + indices_ok = False + if docs_expired and cache_ok: + print("[INFO] Regenerating API indices...") + indices_ok = generate_api_indices() + elif not docs_expired: + indices_ok = True + print(file=sys.stderr) + + macro_ok = False + if docs_expired and cache_ok: + print("[INFO] Regenerating macro factor file...") + macro_ok = refresh_macro_factor_file() + elif not docs_expired: + macro_ok = True + print(file=sys.stderr) + + code_index_ok = build_code_indices() + print(file=sys.stderr) + + if license_ok and cache_ok and indices_ok and macro_ok and code_index_ok: + print("Done") + return 0 + elif license_ok and not cache_ok: + print("[FAIL] Cache init failed - skill cannot be used", file=sys.stderr) + return 2 + elif license_ok and cache_ok and not indices_ok: + print( + "[WARN] API indices generation failed - skill may still work", + file=sys.stderr, + ) + return 0 + else: + print("[FAIL] Initialization failed - skill cannot be used", file=sys.stderr) + return 3 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.cursor/skills/sector-overview/SKILL.md b/.cursor/skills/sector-overview/SKILL.md new file mode 100644 index 000000000..232cd9b4d --- /dev/null +++ b/.cursor/skills/sector-overview/SKILL.md @@ -0,0 +1,415 @@ +--- +name: rq-sector-overview +description: | + 创建模板驱动的行业概览报告,基于显式行业股票池与真实财务/估值/价格数据完成行业层面的结构化分析。 + 使用 RQData CLI 获取股票池、行业分类、财务、估值与区间价格,再由 sector-overview/scripts/generate_report.py 严格按照 `assets/template.md` 生成 Markdown,并在本地可用时渲染 HTML。 + + 务必使用此技能当用户: + - 明确请求行业报告、行业概览、sector overview、行业研究 + - 想看某个赛道/板块的整体财务、竞争格局和估值位置 + - 需要行业内公司对比、龙头排序、集中度分析 + - 需要从行业层面筛选潜在投资机会 + + 不适用场景: + - 单一公司首次覆盖 -> initiating-coverage + - 财报后的单公司点评 -> earnings-analysis + - 只要一句话介绍行业 +compatibility: "Requires rqdata CLI, Bash, python3. Report structure is managed by sector-overview/assets/template.md." +--- + +# RQ 股票研究 - 行业概览 + +## 核心原则 + +- 所有内容必须遵循三阶段流程:数据采集 -> 报告生成 -> HTML 渲染 +- `assets/template.md` 是唯一报告模板来源;Python 只做数据归一化、指标计算、占位符填充和结构校验 +- skill 必须自包含,不能依赖仓库级 `utils` +- 生成器只消费显式的 `stock_pool.json`,不能在代码里硬写“白酒股池”“新能源股池” +- 缺失数据时必须明确写“无数据/未提供”,不能留空 +- 若估值因子文件只有空字符串,必须回溯到最近非空因子日,不能接受“文件有记录但关键章节无数据”的报告 +- 最终 Markdown 必须按客户阅读口径呈现,重点输出行业状态判断、龙头梯队、投资线索和跟踪指标,不能把生成过程写进正文 +- 每个主要章节都必须保留 `数据来源:RQData,置信度5` + +## 数据源分工 + +### `RQData CLI` 负责 + +- 行业股票池、财务、估值、价格与基准表现 +- 行业内公司对比、集中度和横向排序 + +### `web_search` 负责 + +- 市场规模、行业趋势、监管政策 +- 竞争格局、并购整合与主题链背景 + +### `web_search` 禁止替代的内容 + +- 财务、估值、行情和公告等结构化主数据 +- 股票池定义和量化排序结果 + +## `web_search` 使用规则 + +详细字段与约束见 [references/web_search.md](references/web_search.md)。 + +- 所有网络搜索信息必须先写入 `web_search_findings.json` +- 若未提供该文件,报告仍可交付,但只能保持结构化行业概览边界 +- 若提供了网络搜索信息,正文必须真正吸收,而不是只多一个 sidecar 文件 + +## 硬性规则 + +- `[MUST-1]` 先完整收集所有数据,再开始分析和撰写报告 +- `[MUST-2]` 财务、估值和行情数据必须来自 `RQData CLI` +- `[MUST-3]` 市场规模、行业趋势和政策背景必须通过 `web_search` 实时获取,不能依赖训练记忆 +- `[MUST-4]` 市值和金额类数据必须统一换算为“亿元”等客户可读口径 +- `[MUST-5]` 每个关键数据点或关键结论都要标 `数据来源:XXX,置信度X` +- `[MUST-6]` 客户稿不得暴露文件名、字段名或内部 workflow 术语 +- `[MUST-7]` 低置信度网络搜索信息不能单独支撑行业结论或投资线索 + +## 目标产出 + +- 报告长度:10-15 页,正文至少达到约 3000 中文字符 +- 输出文件: + - Markdown 报告 + - HTML 报告(若本地已安装渲染器) +- 输出目录必须由 `--data-dir` / `--output` 指定,不能写死固定路径 + +## 目录结构 + +```text +sector-overview/ +├── SKILL.md +├── scripts/ +│ └── generate_report.py +├── assets/ +│ └── template.md +└── references/ + └── data_contract.md +``` + +## 输入文件契约 + +原始数据目录由 `--data-dir` 指定,脚本会按下列文件名查找输入: + +- `sector_definition.json`:可选 +- `stock_pool.json` +- `instrument_meta.json` +- `industry_map.json` +- `historical_financials.json` +- `latest_financials.json` +- `roe.json` +- `market_cap.json` +- `pe_ratio.json` +- `pb_ratio.json` +- `dividend_yield.json` +- `price_period.json` +- `benchmark_price.json` + +这些文件都允许以下结构: + +- `{ "data": [...] }` +- `{ "data": { ... } }` +- `[...]` +- `{ ... }` + +其中: + +- `stock_pool.json` 定义行业股票池 +- `sector_definition.json` 推荐用于记录行业名、分类级别、分类来源和基准指数 +- `industry_map.json` 用于补充一级/二级/三级行业名 +- `historical_financials.json` / `latest_financials.json` 用于行业财务结构与同比分析 +- `roe.json`、`market_cap.json`、`pe_ratio.json`、`pb_ratio.json`、`dividend_yield.json` 用于竞争格局和估值分析 +- `price_period.json` / `benchmark_price.json` 用于区间表现与相对收益 + +完整字段说明见 [references/data_contract.md](references/data_contract.md)。 + +## 工作流 + +### 步骤 1:准备参数 + +```bash +REPORT_DATE="${REPORT_DATE:-$(date +%F)}" +INDUSTRY_NAME="${INDUSTRY_NAME:-白酒}" +INDUSTRY_LEVEL="${INDUSTRY_LEVEL:-third}" +INDUSTRY_VALUE="${INDUSTRY_VALUE:-白酒}" +INDUSTRY_SOURCE="${INDUSTRY_SOURCE:-citics_2019}" +BENCHMARK_ORDER_BOOK_ID="${BENCHMARK_ORDER_BOOK_ID:-000300.XSHG}" +PRICE_START_DATE="$(python3 - < "$DATA_DIR/stock_list.json" + +python3 - "$DATA_DIR/stock_list.json" "$DATA_DIR/industry_map.json" "$DATA_DIR/stock_pool.json" <<'PY' +import json +import subprocess +import sys +from pathlib import Path + +stock_list_path = Path(sys.argv[1]) +industry_map_path = Path(sys.argv[2]) +stock_pool_path = Path(sys.argv[3]) +definition_path = stock_list_path.parent / "sector_definition.json" +definition = json.loads(definition_path.read_text()) if definition_path.exists() else {} + +industry_level = definition.get("industry_level", "third") +industry_value = definition.get("industry_value") +industry_source = definition.get("industry_source", "citics_2019") +if not industry_value: + raise SystemExit("sector_definition.json 缺少 industry_value") + +payload = json.loads(stock_list_path.read_text()) +items = payload if isinstance(payload, list) else payload.get("data", []) +order_book_ids = [item["order_book_id"] for item in items if isinstance(item, dict) and item.get("order_book_id")] + +industry_rows = [] +for start in range(0, len(order_book_ids), 800): + chunk = order_book_ids[start:start + 800] + cmd = [ + "rqdata", "stock", "cn", "industry", + "--payload", json.dumps({ + "order_book_ids": chunk, + "date": definition.get("factor_date"), + "level": 0, + "source": industry_source, + }, ensure_ascii=False), + "--format", "json", + ] + industry_rows.extend(json.loads(subprocess.check_output(cmd, text=True))) + +industry_map_path.write_text(json.dumps(industry_rows, ensure_ascii=False, indent=2), encoding="utf-8") + +field_map = { + "first": "first_industry_name", + "second": "second_industry_name", + "third": "third_industry_name", +} +field_name = field_map[industry_level] +selected = [row for row in industry_rows if isinstance(row, dict) and row.get(field_name) == industry_value] +stock_pool_path.write_text(json.dumps(selected, ensure_ascii=False, indent=2), encoding="utf-8") +PY +``` + +说明: + +- 脚本会把全 A 股按批查询行业分类,再过滤出目标赛道 +- `sector_definition.json` 建议额外写入 `factor_date`,与下游估值/价格查询保持一致 +- `factor_date` 必须是最近非空因子日,不是“最近交易日”四个字就算完成 +- 如果上游已经有明确成分股,也可以直接提供 `stock_pool.json` + +### 步骤 3:采集行业财务、估值与价格数据 + +```bash +STOCK_POOL_JSON="$(python3 - "$DATA_DIR/stock_pool.json" <<'PY' +import json +import sys +from pathlib import Path +payload = json.loads(Path(sys.argv[1]).read_text()) +items = payload if isinstance(payload, list) else payload.get("data", []) +order_book_ids = [] +for item in items: + if isinstance(item, dict) and item.get("order_book_id"): + order_book_ids.append(item["order_book_id"]) +print(json.dumps(order_book_ids, ensure_ascii=False)) +PY +)" + +rqdata stock cn instruments --payload "{ + \"order_book_ids\": $STOCK_POOL_JSON +}" --format json > "$DATA_DIR/instrument_meta.json" + +rqdata stock cn financial --payload "{ + \"order_book_ids\": $STOCK_POOL_JSON, + \"fields\": [\"revenue\", \"net_profit\", \"gross_profit\", \"total_assets\", \"total_liabilities\", \"cash_from_operating_activities\"], + \"start_quarter\": \"$HISTORY_START_QUARTER\", + \"end_quarter\": \"$HISTORY_END_QUARTER\", + \"statements\": \"all\" +}" --format json > "$DATA_DIR/historical_financials.json" + +cp "$DATA_DIR/historical_financials.json" "$DATA_DIR/latest_financials.json" + +rqdata stock cn financial-indicator --payload "{ + \"order_book_ids\": $STOCK_POOL_JSON, + \"factor\": \"return_on_equity_weighted_average\", + \"start_date\": \"$FACTOR_DATE\", + \"end_date\": \"$FACTOR_DATE\" +}" --format json > "$DATA_DIR/roe.json" + +rqdata stock cn financial-indicator --payload "{ + \"order_book_ids\": $STOCK_POOL_JSON, + \"factor\": \"market_cap\", + \"start_date\": \"$FACTOR_DATE\", + \"end_date\": \"$FACTOR_DATE\" +}" --format json > "$DATA_DIR/market_cap.json" + +rqdata stock cn financial-indicator --payload "{ + \"order_book_ids\": $STOCK_POOL_JSON, + \"factor\": \"pe_ratio\", + \"start_date\": \"$FACTOR_DATE\", + \"end_date\": \"$FACTOR_DATE\" +}" --format json > "$DATA_DIR/pe_ratio.json" + +rqdata stock cn financial-indicator --payload "{ + \"order_book_ids\": $STOCK_POOL_JSON, + \"factor\": \"pb_ratio\", + \"start_date\": \"$FACTOR_DATE\", + \"end_date\": \"$FACTOR_DATE\" +}" --format json > "$DATA_DIR/pb_ratio.json" + +rqdata stock cn financial-indicator --payload "{ + \"order_book_ids\": $STOCK_POOL_JSON, + \"factor\": \"dividend_yield\", + \"start_date\": \"$FACTOR_DATE\", + \"end_date\": \"$FACTOR_DATE\" +}" --format json > "$DATA_DIR/dividend_yield.json" + +rqdata stock cn price --payload "{ + \"order_book_ids\": $STOCK_POOL_JSON, + \"start_date\": \"$PRICE_START_DATE\", + \"end_date\": \"$FACTOR_DATE\", + \"fields\": [\"close\"], + \"adjust_type\": \"post\" +}" --format json > "$DATA_DIR/price_period.json" + +rqdata index price --payload "{ + \"order_book_ids\": [\"$BENCHMARK_ORDER_BOOK_ID\"], + \"start_date\": \"$PRICE_START_DATE\", + \"end_date\": \"$FACTOR_DATE\", + \"fields\": [\"close\"] +}" --format json > "$DATA_DIR/benchmark_price.json" +``` + +### 步骤 4:生成 Markdown 报告 + +```bash +python3 sector-overview/scripts/generate_report.py \ + --industry "$INDUSTRY_NAME" \ + --data-dir "$DATA_DIR" \ + --report-date "$REPORT_DATE" \ + --output "$OUTPUT_MD" +``` + +兼容旧入口: + +```bash +python3 sector-overview/generate_report.py \ + --industry "$INDUSTRY_NAME" \ + --data-dir "$DATA_DIR" \ + --report-date "$REPORT_DATE" \ + --output "$OUTPUT_MD" +``` + +常用参数: + +- `--industry`:行业名称,例如 `白酒` +- `--data-dir`:原始 JSON 数据目录 +- `--report-date`:报告日期,默认当天 +- `--output`:输出 Markdown 路径 +- `--template`:自定义模板路径,默认 `sector-overview/assets/template.md` +- `--no-render`:不生成 HTML + +### 步骤 5:渲染 HTML + +脚本会优先尝试调用本地安装的 `rq-report-renderer`,若不存在则回退到仓库内的 `report-renderer/scripts/render_report.py`;两者都不可用时保留 Markdown 并打印警告。 + +## 模板规则 + +- 报告必须严格基于 [template.md](assets/template.md) 生成 +- 占位符采用 `[[TOKEN]]` 语法,不使用 Jinja +- 当前模板仅允许以下占位符: + - `[[REPORT_DATE]]` + - `[[INDUSTRY_NAME]]` + - `[[EXEC_SUMMARY]]` + - `[[SECTOR_SCOPE]]` + - `[[FINANCIAL_STRUCTURE]]` + - `[[COMPETITION]]` + - `[[VALUATION]]` + - `[[PERFORMANCE]]` + - `[[OPPORTUNITIES]]` + - `[[RISK_SECTION]]` + - `[[APPENDIX]]` + +## 报告质量要求 + +- 完整包含模板中的主章节 +- 行业结论必须基于显式股票池和真实财务/估值/价格数据 +- 不得残留 `[XX]`、`[公司A]`、`[行业机会]` 这类占位文本 +- 必须显式说明股票池覆盖数、主导财报季度和集中度 +- 机会筛选不能只列公司名,必须说明估值/盈利/回报依据 +- `dividend_yield` 原始值单位为 bps,报告中必须换算为百分比后再展示 +- 若市值、ROE、PE/PB 因子整体为空,生成器应直接失败,而不是输出残缺章节 + +## 常见错误 + +- 在代码里硬写“白酒/新能源/银行”股票池 +- 使用固定 `2024q3`、固定 `2024-12-31` +- 把“最近交易日”误当成“最近非空因子日”,导致估值文件虽有记录却全是空值 +- 继续依赖旧版 `utils` 或公共 `sector_analysis` 模块 diff --git a/.cursor/skills/sector-overview/assets/template.md b/.cursor/skills/sector-overview/assets/template.md new file mode 100644 index 000000000..28145509d --- /dev/null +++ b/.cursor/skills/sector-overview/assets/template.md @@ -0,0 +1,34 @@ +# 行业概览报告 + +- 报告日期:[[REPORT_DATE]] +- 行业:[[INDUSTRY_NAME]] + +## 执行摘要 +[[EXEC_SUMMARY]] + +## 行业定义与样本覆盖 +[[SECTOR_SCOPE]] + +## 行业状态判断 +[[SECTOR_STATE]] + +## 行业财务与盈利结构 +[[FINANCIAL_STRUCTURE]] + +## 竞争格局与集中度 +[[COMPETITION]] + +## 估值与股东回报 +[[VALUATION]] + +## 区间表现与相对收益 +[[PERFORMANCE]] + +## 投资线索与跟踪指标 +[[INVESTMENT_FRAMEWORK]] + +## 风险提示 +[[RISK_SECTION]] + +## 附录:口径说明 +[[APPENDIX]] diff --git a/.cursor/skills/sector-overview/references/data_contract.md b/.cursor/skills/sector-overview/references/data_contract.md new file mode 100644 index 000000000..b4241e454 --- /dev/null +++ b/.cursor/skills/sector-overview/references/data_contract.md @@ -0,0 +1,187 @@ +# sector-overview 数据契约 + +`sector-overview/scripts/generate_report.py` 默认从 `--data-dir` 读取以下 JSON 文件。 + +## 1. `sector_definition.json` + +可选文件。推荐字段: + +- `industry_name` +- `industry_level` +- `industry_value` +- `industry_source` +- `benchmark_order_book_id` +- `factor_date` + +用途: + +- 记录股票池生成规则和基准指数 + +## 2. `stock_pool.json` + +允许格式: + +```json +[ + { "order_book_id": "600519.XSHG" } +] +``` + +也允许直接包含 `industry level 0` 的完整行。 + +用途: + +- 定义行业成分股 + +## 3. `instrument_meta.json` + +典型字段: + +- `order_book_id` +- `symbol` +- `sector_code_name` +- `industry_name` + +用途: + +- 提供公司名称和基础元数据 + +## 4. `industry_map.json` + +典型字段: + +- `order_book_id` +- `first_industry_name` +- `second_industry_name` +- `third_industry_name` + +用途: + +- 行业分层描述 +- 校验股票池确实来自同一行业口径 + +## 5. `historical_financials.json` / `latest_financials.json` + +典型字段: + +- `order_book_id` +- `quarter` +- `info_date` +- `revenue` +- `net_profit` +- `gross_profit` +- `total_assets` +- `total_liabilities` +- `cash_from_operating_activities` + +用途: + +- 行业整体财务结构 +- 计算营收/利润同比、毛利率、资产负债率、现金转化率 + +## 6. `roe.json` + +典型字段: + +- `order_book_id` +- `date` +- `return_on_equity_weighted_average` + +用途: + +- 行业盈利质量和龙头对比 + +## 7. `market_cap.json` + +典型字段: + +- `order_book_id` +- `date` +- `market_cap` + +用途: + +- 行业总市值、CR3/CR5/CR10 计算 +- 龙头梯队与集中度分析 + +## 8. `pe_ratio.json` / `pb_ratio.json` / `dividend_yield.json` + +典型字段: + +- `order_book_id` +- `date` +- `pe_ratio` / `pb_ratio` / `dividend_yield` + +说明: + +- `dividend_yield` 原始值为 bps,生成报告时需要除以 `100` 后按百分比展示 + +用途: + +- 行业估值中位数 +- 机会筛选 +- 股东回报比较 + +补充说明: + +- `PE` 相关横向比较默认以正值样本为主,亏损或异常高估值样本会单独作为压力样本观察,不直接充当“低估值”候选 + +## 9. `price_period.json` + +典型字段: + +- `order_book_id` +- `datetime` +- `close` + +用途: + +- 计算股票池区间收益 + +## 10. `benchmark_price.json` + +典型字段: + +- `order_book_id` +- `datetime` +- `close` + +用途: + +- 计算行业相对基准的超额收益 + +## 11. `web_search_findings.json` + +该文件可选,仅用于补充市场规模、行业趋势、监管政策、竞争格局和并购整合等网络搜索信息。 + +每条记录至少包含: + +- `query` +- `source_name` +- `source_type` +- `title` +- `url` +- `published_at` +- `retrieved_at` +- `summary` +- `why_relevant` +- `confidence` +- `finding_type` + +推荐附加字段: + +- `subject` +- `related_entities` + +用途: + +- 补充 RQData 无法直接提供的行业背景层 +- 为行业状态判断和投资线索提供实时网络搜索信息 + +## 解析约定 + +- 所有文件都允许 `{"data": [...]}`、`{"data": {...}}`、`[...]`、`{...}` 四种包装方式 +- 同一股票同一季度若存在多条财务记录,脚本会按 `info_date` 选择最新披露版本 +- 估值和价格文件必须使用最近非空因子日;如果最近交易日返回空字符串,需要继续向前回溯 +- `web_search_findings.json` 不能替代财务、估值、行情和公告等结构化主数据 +- 报告正文面向客户阅读,附录仅保留必要口径说明,不回显输入文件清单或执行过程 diff --git a/.cursor/skills/sector-overview/references/web_search.md b/.cursor/skills/sector-overview/references/web_search.md new file mode 100644 index 000000000..deaf3618b --- /dev/null +++ b/.cursor/skills/sector-overview/references/web_search.md @@ -0,0 +1,45 @@ +# Sector Overview Web Search Reference + +## Purpose + +Use `web_search` only to supplement market size, industry trend, policy, competition, and M&A information that `RQData CLI` does not directly provide for a sector-overview report. + +## Allowed Coverage + +- Market size and growth outlook +- Industry trend and demand drivers +- Regulatory and policy developments +- Competitive structure and market share context +- M&A and industry consolidation updates + +## Prohibited Usage + +- Do not replace prices, valuation factors, financials, or announcements +- Do not use `web_search` to fabricate hard financial facts +- Do not let low-confidence network-search information override structured sector data + +## Required Output File + +All network-search findings must be written to `web_search_findings.json`. + +Each record must contain: + +- `query` +- `source_name` +- `source_type` +- `title` +- `url` +- `published_at` +- `retrieved_at` +- `summary` +- `why_relevant` +- `confidence` +- `finding_type` + +## Allowed `finding_type` + +- `market_size_context` +- `trend_context` +- `policy_context` +- `competition_context` +- `mna_context` diff --git a/.cursor/skills/sector-overview/scripts/generate_report.py b/.cursor/skills/sector-overview/scripts/generate_report.py new file mode 100644 index 000000000..b805a8a65 --- /dev/null +++ b/.cursor/skills/sector-overview/scripts/generate_report.py @@ -0,0 +1,1183 @@ +#!/usr/bin/env python3 +"""Template-driven sector overview report generator.""" + +from __future__ import annotations + +import argparse +import json +import re +import shutil +import subprocess +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 + + +TOKEN_RE = re.compile(r"\[\[([A-Z0-9_]+)\]\]") +REQUIRED_HEADINGS = [ + "# 行业概览报告", + "## 执行摘要", + "## 行业定义与样本覆盖", + "## 行业状态判断", + "## 行业财务与盈利结构", + "## 竞争格局与集中度", + "## 估值与股东回报", + "## 区间表现与相对收益", + "## 投资线索与跟踪指标", + "## 风险提示", + "## 附录:口径说明", +] +WEB_SOURCE_CONFIDENCE_CEILING = { + "official": 5, + "government": 4, + "association": 4, + "authoritative_media": 4, + "general_news": 3, + "inference": 1, +} +WEB_ALLOWED_FINDING_TYPES = { + "market_size_context", + "trend_context", + "policy_context", + "competition_context", + "mna_context", +} + +TICKER_FIELDS = ("order_book_id", "ticker", "stock_code", "symbol", "code") +NAME_FIELDS = ("display_name", "name", "stock_name", "company_name", "symbol_name", "symbol") + + +def parse_args() -> argparse.Namespace: + skill_dir = Path(__file__).resolve().parent.parent + parser = argparse.ArgumentParser(description="生成模板驱动的行业概览报告") + parser.add_argument("--industry", required=True, 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="输出 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 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 float_or_none(value: Any) -> Optional[float]: + if value in (None, "", "null"): + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + +def clean_text(value: Any) -> str: + return re.sub(r"\s+", " ", str(value or "")).strip() + + +def validate_web_search_records(records: Sequence[Any]) -> None: + if not records: + return + required_fields = { + "query", + "source_name", + "source_type", + "title", + "url", + "published_at", + "retrieved_at", + "summary", + "why_relevant", + "confidence", + "finding_type", + } + issues: List[str] = [] + for idx, item in enumerate(records, start=1): + if not isinstance(item, dict): + issues.append(f"第 {idx} 条网络搜索信息记录不是对象") + continue + missing = [field for field in required_fields if item.get(field) in (None, "", "null")] + if missing: + issues.append(f"第 {idx} 条网络搜索信息记录缺少字段:{', '.join(missing)}") + source_type = str(item.get("source_type") or "").strip() + if source_type not in WEB_SOURCE_CONFIDENCE_CEILING: + issues.append(f"第 {idx} 条网络搜索信息记录来源类型非法:{source_type or '空'}") + confidence = float_or_none(item.get("confidence")) + ceiling = WEB_SOURCE_CONFIDENCE_CEILING.get(source_type) + if confidence is None: + issues.append(f"第 {idx} 条网络搜索信息记录缺少置信度") + elif ceiling is not None and confidence > ceiling: + issues.append(f"第 {idx} 条网络搜索信息记录置信度 {confidence:g} 超过来源上限 {ceiling}") + finding_type = str(item.get("finding_type") or "").strip() + if finding_type not in WEB_ALLOWED_FINDING_TYPES: + issues.append(f"第 {idx} 条网络搜索信息记录 finding_type 非法:{finding_type or '空'}") + if issues: + raise ValueError("网络搜索信息校验失败:" + ";".join(issues)) + + +def extract_external_findings(records: Sequence[Any]) -> List[Dict[str, Any]]: + findings: List[Dict[str, Any]] = [] + for item in records: + if not isinstance(item, dict): + continue + published_at = parse_iso_date(item.get("published_at")) + if published_at is None: + continue + findings.append( + { + "source_name": str(item.get("source_name") or "外部来源").strip(), + "title": clean_text(item.get("title")), + "published_at": published_at, + "summary": clean_text(item.get("summary")).rstrip("。;;!!??"), + "why_relevant": clean_text(item.get("why_relevant")).rstrip("。;;!!??"), + "confidence": int(float_or_none(item.get("confidence")) or 0), + } + ) + findings.sort(key=lambda item: (item["published_at"], item["confidence"]), reverse=True) + return findings + + +def billion_yuan_text(value: Optional[float]) -> str: + if value is None: + return "无数据" + return f"{value / 1e8:.2f}亿元" + + +def percent_text(value: Optional[float], digits: int = 1, signed: bool = True) -> str: + if value is None: + return "无数据" + sign = "+" if signed else "" + return f"{value:{sign}.{digits}f}%" + + +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 average_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(sum(valid) / len(valid)) + + +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 count_if(rows: Sequence[Dict[str, Any]], predicate) -> int: + return sum(1 for item in rows if predicate(item)) + + +def safe_ratio(numerator: int, denominator: int) -> Optional[float]: + if denominator <= 0: + return None + return numerator / denominator * 100.0 + + +def format_company_list(items: Sequence[Dict[str, Any]], field_name: str, formatter, limit: int = 5) -> str: + picked = [] + for item in items[:limit]: + picked.append(f"{item['name']}({formatter(item.get(field_name))})") + return "、".join(picked) if picked else "无数据" + + +def ranked_rows(rows: Sequence[Dict[str, Any]], field_name: str, reverse: bool = True, require_positive: bool = False) -> List[Dict[str, Any]]: + ranked = [] + for item in rows: + value = item.get(field_name) + if value is None: + continue + if require_positive and value <= 0: + continue + ranked.append(item) + return sorted(ranked, key=lambda item: item.get(field_name) or 0.0, reverse=reverse) + + +def quoted(value: str) -> str: + return f"`{value}`" if value else "`无数据`" + + +def is_special_treatment(item: Dict[str, Any]) -> bool: + name = str(item.get("name") or "") + return "ST" in name.upper() + + +def build_stock_pool(records: List[Any]) -> List[str]: + order_book_ids: List[str] = [] + seen = set() + for item in records: + if isinstance(item, str): + stock = item.strip() + elif isinstance(item, dict): + stock = normalize_ticker(item) + else: + continue + if not stock or stock in seen: + continue + seen.add(stock) + order_book_ids.append(stock) + return order_book_ids + + +def build_name_map(records: List[Any], stock_ids: Sequence[str]) -> Dict[str, str]: + stock_set = set(stock_ids) + result = {} + for item in records: + if not isinstance(item, dict): + continue + stock = normalize_ticker(item) + if stock in stock_set: + result[stock] = normalize_name(item) or stock + return result + + +def build_industry_map(records: List[Any], stock_ids: Sequence[str]) -> Dict[str, Dict[str, str]]: + stock_set = set(stock_ids) + result = {} + for item in records: + if not isinstance(item, dict): + continue + stock = normalize_ticker(item) + if stock not in stock_set: + continue + result[stock] = { + "first": str(item.get("first_industry_name") or "无数据"), + "second": str(item.get("second_industry_name") or "无数据"), + "third": str(item.get("third_industry_name") or "无数据"), + } + 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 latest_by_stock(records: List[Dict[str, Any]]) -> Dict[str, Dict[str, Any]]: + grouped: Dict[str, Dict[str, Any]] = {} + for item in records: + stock = normalize_ticker(item) + current = grouped.get(stock) + current_key = parse_quarter_key(str(current.get("quarter") or "").lower()) if current else None + item_key = parse_quarter_key(str(item.get("quarter") or "").lower()) + if current is None or (item_key and (current_key is None or item_key >= current_key)): + grouped[stock] = item + return grouped + + +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 field_name == "dividend_yield" and value is not None: + value = value / 100.0 + 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 dominant_quarter(latest_records: Dict[str, Dict[str, Any]]) -> Optional[str]: + counts = Counter(str(item.get("quarter") or "").lower() for item in latest_records.values() if item.get("quarter")) + return counts.most_common(1)[0][0] if counts else None + + +def same_quarter_last_year(quarter: str) -> Optional[str]: + key = parse_quarter_key(quarter) + if not key: + return None + return f"{key[0] - 1}q{key[1]}" + + +def build_sector_rows( + stock_ids: Sequence[str], + instrument_records: List[Any], + industry_records: List[Any], + financial_records: List[Any], + roe_records: List[Any], + market_cap_records: List[Any], + pe_records: List[Any], + pb_records: List[Any], + dividend_records: List[Any], +) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]: + names = build_name_map(instrument_records, stock_ids) + industries = build_industry_map(industry_records, stock_ids) + financial_deduped = dedupe_financial_records(financial_records, stock_ids) + latest_records = latest_by_stock(financial_deduped) + dominant = dominant_quarter(latest_records) + yoy_quarter = same_quarter_last_year(dominant) if dominant else None + + by_stock_quarter = {(normalize_ticker(item), str(item.get("quarter") or "").lower()): item for item in financial_deduped} + 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") + dividend_map = latest_factor_map(dividend_records, stock_ids, "dividend_yield") + + rows: List[Dict[str, Any]] = [] + coverage_counts = Counter(str(item.get("quarter") or "").lower() for item in latest_records.values()) + for stock in stock_ids: + latest = latest_records.get(stock) + if latest is None or str(latest.get("quarter") or "").lower() != dominant: + continue + yoy = by_stock_quarter.get((stock, yoy_quarter or "")) + revenue = float_or_none(latest.get("revenue")) + net_profit = float_or_none(latest.get("net_profit")) + gross_profit = float_or_none(latest.get("gross_profit")) + total_assets = float_or_none(latest.get("total_assets")) + total_liabilities = float_or_none(latest.get("total_liabilities")) + cash_flow = float_or_none(latest.get("cash_from_operating_activities")) + yoy_revenue = float_or_none(yoy.get("revenue")) if yoy else None + yoy_profit = float_or_none(yoy.get("net_profit")) if yoy else None + 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 + gross_margin = ((gross_profit / revenue) * 100.0) if gross_profit is not None and revenue not in (None, 0) else None + net_margin = ((net_profit / revenue) * 100.0) if net_profit is not None and revenue 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 + cash_conversion = (cash_flow / net_profit) if cash_flow is not None and net_profit not in (None, 0) else None + + industry_meta = industries.get(stock, {"first": "无数据", "second": "无数据", "third": "无数据"}) + rows.append( + { + "order_book_id": stock, + "name": names.get(stock, stock), + "quarter": dominant, + "first_industry": industry_meta["first"], + "second_industry": industry_meta["second"], + "third_industry": industry_meta["third"], + "revenue": revenue, + "net_profit": net_profit, + "gross_margin": gross_margin, + "net_margin": net_margin, + "debt_ratio": debt_ratio, + "cash_conversion": cash_conversion, + "revenue_yoy": revenue_yoy, + "profit_yoy": profit_yoy, + "roe": roe_map.get(stock), + "market_cap": market_cap_map.get(stock), + "pe": pe_map.get(stock), + "pb": pb_map.get(stock), + "dividend_yield": dividend_map.get(stock), + } + ) + + stats = { + "pool_size": len(stock_ids), + "covered_size": len(rows), + "dominant_quarter": dominant, + "yoy_quarter": yoy_quarter, + "quarter_distribution": coverage_counts, + } + return rows, stats + + +def build_return_map(price_records: List[Any], stock_ids: Sequence[str]) -> Dict[str, float]: + stock_set = set(stock_ids) + series: Dict[str, Dict[date, float]] = {stock: {} for stock in stock_set} + for item in price_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("datetime") or item.get("date")) + close = float_or_none(item.get("close")) + if event_date is None or close is None: + continue + series[stock][event_date] = close + + returns: Dict[str, float] = {} + for stock, points in series.items(): + ordered = sorted(points.items(), key=lambda pair: pair[0]) + if len(ordered) < 2 or ordered[0][1] in (None, 0): + continue + returns[stock] = (ordered[-1][1] / ordered[0][1] - 1.0) * 100.0 + return returns + + +def build_benchmark_return(records: List[Any]) -> Optional[float]: + points: Dict[date, float] = {} + for item in records: + if not isinstance(item, dict): + continue + event_date = parse_iso_date(item.get("datetime") or item.get("date")) + close = float_or_none(item.get("close")) + if event_date is None or close is None: + continue + points[event_date] = close + ordered = sorted(points.items(), key=lambda pair: pair[0]) + if len(ordered) < 2 or ordered[0][1] in (None, 0): + return None + return (ordered[-1][1] / ordered[0][1] - 1.0) * 100.0 + + +def sector_summary(rows: Sequence[Dict[str, Any]], returns: Dict[str, float], benchmark_return: Optional[float]) -> Dict[str, Any]: + total_market_cap = sum(item.get("market_cap") or 0.0 for item in rows) + total_revenue = sum(item.get("revenue") or 0.0 for item in rows) + total_profit = sum(item.get("net_profit") or 0.0 for item in rows) + gross_profit_agg = sum((item.get("gross_margin") or 0.0) / 100.0 * (item.get("revenue") or 0.0) for item in rows if item.get("gross_margin") is not None and item.get("revenue") is not None) + gross_margin = (gross_profit_agg / total_revenue * 100.0) if total_revenue else None + + sector_return_avg = median_or_none(returns.values()) + excess = sector_return_avg - benchmark_return if sector_return_avg is not None and benchmark_return is not None else None + positive_count = sum(1 for value in returns.values() if value > 0) + revenue_positive_count = count_if(rows, lambda item: (item.get("revenue_yoy") or -9999.0) > 0) + profit_positive_count = count_if(rows, lambda item: (item.get("profit_yoy") or -9999.0) > 0) + high_roe_count = count_if(rows, lambda item: (item.get("roe") or -9999.0) >= 10.0) + low_pe_count = count_if(rows, lambda item: item.get("pe") is not None and 0 < item["pe"] <= 15.0) + dividend_payer_count = count_if(rows, lambda item: (item.get("dividend_yield") or 0.0) > 0) + loss_making_count = count_if(rows, lambda item: (item.get("net_profit") or 0.0) < 0) + high_cash_count = count_if(rows, lambda item: (item.get("cash_conversion") or 0.0) >= 1.0) + positive_pe_values = [item.get("pe") for item in rows if item.get("pe") is not None and item.get("pe") > 0] + positive_pb_values = [item.get("pb") for item in rows if item.get("pb") is not None and item.get("pb") > 0] + return { + "total_market_cap": total_market_cap if total_market_cap else None, + "total_revenue": total_revenue if total_revenue else None, + "total_profit": total_profit if total_profit else None, + "gross_margin": gross_margin, + "revenue_yoy_median": median_or_none(item.get("revenue_yoy") for item in rows), + "profit_yoy_median": median_or_none(item.get("profit_yoy") for item in rows), + "roe_median": median_or_none(item.get("roe") for item in rows), + "pe_median": median_or_none(positive_pe_values), + "pb_median": median_or_none(positive_pb_values), + "dividend_median": median_or_none(item.get("dividend_yield") for item in rows), + "debt_ratio_median": median_or_none(item.get("debt_ratio") for item in rows), + "cash_conversion_median": median_or_none(item.get("cash_conversion") for item in rows), + "sector_return_median": sector_return_avg, + "benchmark_return": benchmark_return, + "excess_return": excess, + "positive_count": positive_count, + "breadth": (positive_count / len(returns) * 100.0) if returns else None, + "revenue_positive_count": revenue_positive_count, + "profit_positive_count": profit_positive_count, + "high_roe_count": high_roe_count, + "low_pe_count": low_pe_count, + "dividend_payer_count": dividend_payer_count, + "loss_making_count": loss_making_count, + "high_cash_count": high_cash_count, + "revenue_positive_ratio": safe_ratio(revenue_positive_count, len(rows)), + "profit_positive_ratio": safe_ratio(profit_positive_count, len(rows)), + "high_roe_ratio": safe_ratio(high_roe_count, len(rows)), + "dividend_payer_ratio": safe_ratio(dividend_payer_count, len(rows)), + "cash_conversion_ratio": safe_ratio(high_cash_count, len(rows)), + "pe_average": average_or_none(positive_pe_values), + "pb_average": average_or_none(positive_pb_values), + } + + +def populated_count(rows: Sequence[Dict[str, Any]], field_name: str) -> int: + return sum(1 for item in rows if item.get(field_name) is not None) + + +def validate_dataset_quality( + stock_ids: Sequence[str], + rows: Sequence[Dict[str, Any]], + returns: Dict[str, float], + summary: Dict[str, Any], +) -> None: + if not rows: + raise ValueError("主导财报季度样本为空,无法生成行业概览报告。") + + issues: List[str] = [] + market_cap_coverage = populated_count(rows, "market_cap") + roe_coverage = populated_count(rows, "roe") + pe_coverage = populated_count(rows, "pe") + pb_coverage = populated_count(rows, "pb") + dividend_coverage = populated_count(rows, "dividend_yield") + return_coverage = len(returns) + + if market_cap_coverage == 0 or summary.get("total_market_cap") is None: + issues.append("市值因子全部为空;请把 financial-indicator 日期回溯到最近非空交易日后重试。") + if roe_coverage == 0: + issues.append("ROE 因子全部为空;当前因子日期不可用。") + if pe_coverage == 0 and pb_coverage == 0: + issues.append("PE/PB 因子全部为空;估值章节无法成立。") + if dividend_coverage == 0: + issues.append("股息率因子全部为空;股东回报章节无法成立。") + if return_coverage < max(3, min(len(stock_ids), 5)): + issues.append("区间价格覆盖不足;相对收益章节无法稳定成立。") + + if issues: + raise ValueError("数据质量校验失败:" + ";".join(issues)) + + +def concentration(market_caps: Sequence[Optional[float]], top_n: int) -> Optional[float]: + valid = sorted((value for value in market_caps if value is not None), reverse=True) + total = sum(valid) + if total <= 0 or len(valid) < top_n: + return None + return sum(valid[:top_n]) / total * 100.0 + + +def classify_operating_state(summary: Dict[str, Any]) -> str: + revenue_yoy = summary.get("revenue_yoy_median") + profit_yoy = summary.get("profit_yoy_median") + if revenue_yoy is None or profit_yoy is None: + return "数据不足,暂难判断景气阶段" + if revenue_yoy >= 10 and profit_yoy >= 10: + return "收入与利润同步扩张,处于景气上行阶段" + if revenue_yoy >= 0 and profit_yoy < 0: + return "收入修复领先于利润兑现,处于复苏早期" + if revenue_yoy < 0 and profit_yoy >= 0: + return "需求偏弱但盈利仍在修复,处于利润韧性阶段" + return "收入和利润均承压,处于主动去库存或需求偏弱阶段" + + +def classify_market_style(summary: Dict[str, Any], rows: Sequence[Dict[str, Any]]) -> str: + breadth = summary.get("breadth") + excess = summary.get("excess_return") + cr5 = concentration([item.get("market_cap") for item in rows], 5) + if breadth is None or excess is None: + return "市场风格判断不足" + if excess > 5 and breadth >= 60: + return "行业整体强于基准,资金参与面较广" + if excess > 5 and breadth < 60: + return "行业跑赢基准,但行情主要集中于少数龙头" + if excess <= 5 and breadth >= 50: + return "股价表现分散,行业内部轮动多于整体贝塔" + if cr5 is not None and cr5 >= 70: + return "市场对龙头更偏好,二线公司尚未形成共振" + return "行业整体交易热度偏弱,仍需等待基本面验证" + + +def major_company_line(items: Sequence[Dict[str, Any]], field_name: str, formatter, limit: int = 5) -> str: + chosen = [item for item in ranked_rows(items, field_name, reverse=True) if not is_special_treatment(item)][:limit] + return format_company_list(chosen, field_name, formatter, limit=limit) + + +def build_external_context_block(external_findings: Sequence[Dict[str, Any]], title: str) -> List[str]: + if not external_findings: + return [] + lines = [title] + for item in external_findings[:5]: + lines.append( + f"- **{item['published_at'].isoformat()} {item['source_name']}**:{item['title']}。{item['summary']}。" + f" 对本次行业判断的意义:{item['why_relevant']}。 *数据来源:{item['source_name']},置信度{item['confidence']}*" + ) + return lines + + +def build_exec_summary( + industry: str, + stats: Dict[str, Any], + summary: Dict[str, Any], + rows: Sequence[Dict[str, Any]], + external_findings: Sequence[Dict[str, Any]] = (), +) -> str: + top_company = max(rows, key=lambda item: item.get("market_cap") or 0.0) if rows else None + cr5 = concentration([item.get("market_cap") for item in rows], 5) + operating_state = classify_operating_state(summary) + market_style = classify_market_style(summary, rows) + lines = [ + f"- 一句话判断:`{industry}` 当前呈现“{operating_state}”特征,{market_style}。", + f"- 样本覆盖:股票池 {stats['pool_size']} 家,进入主导财报季度 {quoted(stats['dominant_quarter'] or '无数据')} 比较口径的公司 {stats['covered_size']} 家,覆盖率约 {percent_text(safe_ratio(stats['covered_size'], stats['pool_size']), signed=False)}。", + f"- 行业体量:总市值约为 {billion_yuan_text(summary.get('total_market_cap'))},主导季度合计营收 {billion_yuan_text(summary.get('total_revenue'))}、净利润 {billion_yuan_text(summary.get('total_profit'))},整体毛利率约为 {percent_text(summary.get('gross_margin'), signed=False)}。", + f"- 景气读数:营收同比中位数 {percent_text(summary.get('revenue_yoy_median'))},净利润同比中位数 {percent_text(summary.get('profit_yoy_median'))},其中营收正增长公司占比 {percent_text(summary.get('revenue_positive_ratio'), signed=False)},利润正增长公司占比 {percent_text(summary.get('profit_positive_ratio'), signed=False)}。", + f"- 质量与估值:ROE 中位数 {percent_text(summary.get('roe_median'), signed=False)},股息率中位数 {percent_text(summary.get('dividend_median'), signed=False)},正 PE 样本均值 / 中位数分别为 {x_text(summary.get('pe_average'), 1)} / {x_text(summary.get('pe_median'), 1)}。", + f"- 交易表现:近 180 天行业收益中位数 {percent_text(summary.get('sector_return_median'))},相对基准超额收益 {percent_text(summary.get('excess_return'))},上涨家数占比 {percent_text(summary.get('breadth'), signed=False)},CR5 约为 {percent_text(cr5, signed=False)}。", + f"- 龙头定位:当前市值龙头为 **{top_company['name']}**({top_company['order_book_id']}),总市值约 {billion_yuan_text(top_company.get('market_cap'))}。" + if top_company + else "- 当前没有足够数据识别行业龙头。", + "", + "*数据来源:RQData,置信度5*", + ] + if external_findings: + top_finding = external_findings[0] + lines.append( + f"- 网络搜索信息补充:{top_finding['source_name']} 在 {top_finding['published_at'].isoformat()} 提到“{top_finding['title']}”," + f"可用于补充行业背景层和景气判断。 *数据来源:{top_finding['source_name']},置信度{top_finding['confidence']}*" + ) + return "\n".join(lines) + + +def build_scope_section(industry: str, sector_definition: Optional[Dict[str, Any]], stats: Dict[str, Any], rows: Sequence[Dict[str, Any]]) -> str: + quarter_lines = [f"- `{quarter}`:{count} 家" for quarter, count in stats["quarter_distribution"].most_common(5)] or ["- 无数据。"] + first_levels = Counter(item.get("first_industry") for item in rows if item.get("first_industry")) + second_levels = Counter(item.get("second_industry") for item in rows if item.get("second_industry")) + third_levels = Counter(item.get("third_industry") for item in rows if item.get("third_industry")) + top_by_cap = major_company_line(rows, "market_cap", lambda value: billion_yuan_text(value)) + top_by_revenue = major_company_line(rows, "revenue", lambda value: billion_yuan_text(value)) + lines = [ + f"- 本报告使用 `{industry}` 作为行业名称;当前分类来源为 `{(sector_definition or {}).get('industry_source', '未提供')}`,层级为 `{(sector_definition or {}).get('industry_level', '未提供')}`。", + f"- 股票池总数为 {stats['pool_size']} 家,进入主导季度比较口径的公司为 {stats['covered_size']} 家;未纳入主导季度比较的样本通常是财报期次不同或关键字段缺失。", + f"- 当前市值前列公司主要包括:{top_by_cap}。", + f"- 当前营收体量前列公司主要包括:{top_by_revenue}。", + "", + "### 主导财报季度分布", + *quarter_lines, + "", + "### 行业分类校验", + f"- 一级行业最常见分类:`{(first_levels.most_common(1)[0][0] if first_levels else '无数据')}`", + f"- 二级行业最常见分类:`{(second_levels.most_common(1)[0][0] if second_levels else '无数据')}`", + f"- 三级行业最常见分类:`{(third_levels.most_common(1)[0][0] if third_levels else '无数据')}`", + "- 若分类映射为空,通常意味着上游只提供了显式股票池而未附行业层级映射;这不会影响财务和估值统计,但会降低口径复核能力。", + "", + "*数据来源:RQData,置信度5*", + ] + return "\n".join(lines) + + +def build_sector_state( + industry: str, + summary: Dict[str, Any], + rows: Sequence[Dict[str, Any]], + external_findings: Sequence[Dict[str, Any]] = (), +) -> str: + operating_state = classify_operating_state(summary) + market_style = classify_market_style(summary, rows) + filtered_rows = [item for item in rows if not is_special_treatment(item)] + revenue_leaders = format_company_list(ranked_rows(filtered_rows, "revenue_yoy", reverse=True), "revenue_yoy", lambda value: percent_text(value), limit=4) + profit_leaders = format_company_list(ranked_rows(filtered_rows, "profit_yoy", reverse=True), "profit_yoy", lambda value: percent_text(value), limit=4) + roe_leaders = format_company_list(ranked_rows(filtered_rows, "roe", reverse=True), "roe", lambda value: percent_text(value, signed=False), limit=4) + low_pe_names = format_company_list(ranked_rows(filtered_rows, "pe", reverse=False, require_positive=True), "pe", lambda value: x_text(value, 1), limit=4) + lines = [ + f"- 当前行业状态判断为:**{operating_state}**。", + f"- 从覆盖样本看,营收正增长公司占比约 {percent_text(summary.get('revenue_positive_ratio'), signed=False)},利润正增长公司占比约 {percent_text(summary.get('profit_positive_ratio'), signed=False)},说明 `{industry}` 内部修复并不完全同步。", + f"- 市场交易层面呈现:**{market_style}**。近 180 天行业收益中位数 {percent_text(summary.get('sector_return_median'))},超额收益 {percent_text(summary.get('excess_return'))},上涨广度 {percent_text(summary.get('breadth'), signed=False)}。", + f"- 质量层面,ROE 不低于 10% 的公司占比约 {percent_text(summary.get('high_roe_ratio'), signed=False)},现金转化率不低于 1x 的公司占比约 {percent_text(summary.get('cash_conversion_ratio'), signed=False)},说明行业内部仍可区分出具备盈利兑现能力的经营主体。", + f"- 回报层面,有股息回报记录的公司占比约 {percent_text(summary.get('dividend_payer_ratio'), signed=False)};若行业股息率中位数偏低,通常意味着资金更看重成长兑现而非现金回报。", + f"- 背离观察:行业收入增速与利润增速之间存在 {percent_text((summary.get('revenue_yoy_median') or 0.0) - (summary.get('profit_yoy_median') or 0.0), signed=False)} 的差值,这通常意味着成本、费用或产品结构变化仍在影响利润释放节奏。", + "", + "### 关键观察", + f"- 营收增速领先公司:{revenue_leaders}。", + f"- 利润修复领先公司:{profit_leaders}。", + f"- ROE 领先公司:{roe_leaders}。", + f"- 正 PE 口径下的低估值样本:{low_pe_names}。", + "- 若后续行业出现“营收正增长占比抬升、利润正增长占比同步抬升、上涨广度扩散”三项共振,通常意味着行业状态从结构性修复转向更广泛的景气改善。", + "", + "*数据来源:RQData,置信度5*", + ] + if external_findings: + lines.extend(["", *build_external_context_block(external_findings, "### 外部行业语境")]) + return "\n".join(lines) + + +def build_financial_section(rows: Sequence[Dict[str, Any]], summary: Dict[str, Any], stats: Dict[str, Any]) -> str: + top_revenue = sorted(rows, key=lambda item: item.get("revenue") or 0.0, reverse=True)[:10] + filtered_rows = [item for item in rows if not is_special_treatment(item)] + revenue_growers = ranked_rows(filtered_rows, "revenue_yoy", reverse=True)[:5] + profit_growers = ranked_rows(filtered_rows, "profit_yoy", reverse=True)[:5] + margin_leaders = ranked_rows(filtered_rows, "gross_margin", reverse=True)[:5] + headers = ["公司", "营收", "营收同比", "净利润", "净利润同比", "毛利率", "现金转化率"] + table_rows = [ + [ + f"{item['name']}
    `{item['order_book_id']}`", + billion_yuan_text(item.get("revenue")), + percent_text(item.get("revenue_yoy")), + billion_yuan_text(item.get("net_profit")), + percent_text(item.get("profit_yoy")), + percent_text(item.get("gross_margin"), signed=False), + x_text(item.get("cash_conversion")), + ] + for item in top_revenue + ] + return "\n".join( + [ + f"- 主导比较口径为 `{stats['dominant_quarter'] or '无数据'}` 对比 `{stats['yoy_quarter'] or '无数据'}`。", + f"- 行业营收同比中位数约为 {percent_text(summary.get('revenue_yoy_median'))},净利润同比中位数约为 {percent_text(summary.get('profit_yoy_median'))}。", + f"- 行业整体毛利率约为 {percent_text(summary.get('gross_margin'), signed=False)},资产负债率中位数约为 {percent_text(summary.get('debt_ratio_median'), signed=False)},现金转化率中位数约为 {x_text(summary.get('cash_conversion_median'))}。", + f"- 当前亏损样本数量约为 {summary.get('loss_making_count') or 0} 家,反映行业内部仍存在尚未越过盈亏平衡点的公司。", + "", + format_table(headers, table_rows), + "", + "### 财务结构观察", + f"- 营收增速领先样本:{format_company_list(revenue_growers, 'revenue_yoy', lambda value: percent_text(value), limit=5)}。", + f"- 利润增速领先样本:{format_company_list(profit_growers, 'profit_yoy', lambda value: percent_text(value), limit=5)}。", + f"- 毛利率领先样本:{format_company_list(margin_leaders, 'gross_margin', lambda value: percent_text(value, signed=False), limit=5)}。", + "- 如果营收同比改善而利润同比未同步,通常意味着产品价格、折旧摊销、研发投放或渠道费用仍在压制利润释放。", + "- 如果现金转化率显著高于 1x,说明当前利润兑现质量较好;但若极端偏高,也要结合一次性回款或营运资本波动理解。", + "", + "*数据来源:RQData,置信度5*", + ] + ) + + +def build_competition_section(rows: Sequence[Dict[str, Any]], summary: Dict[str, Any]) -> str: + ranked = sorted(rows, key=lambda item: item.get("market_cap") or 0.0, reverse=True) + filtered_ranked = [item for item in ranked if not is_special_treatment(item)] + top3 = ranked[:3] + next5 = ranked[3:8] + challengers = [item for item in filtered_ranked if (item.get("profit_yoy") or -9999.0) > 20][:5] + headers = ["公司", "市值", "ROE", "营收", "净利润", "PE", "PB"] + table_rows = [ + [ + f"{item['name']}
    `{item['order_book_id']}`", + billion_yuan_text(item.get("market_cap")), + percent_text(item.get("roe"), signed=False), + billion_yuan_text(item.get("revenue")), + billion_yuan_text(item.get("net_profit")), + x_text(item.get("pe"), 1), + x_text(item.get("pb")), + ] + for item in ranked[:10] + ] + return "\n".join( + [ + f"- 行业总市值约为 {billion_yuan_text(summary.get('total_market_cap'))},CR3 / CR5 / CR10 分别约为 " + f"{percent_text(concentration([item.get('market_cap') for item in ranked], 3), signed=False)} / " + f"{percent_text(concentration([item.get('market_cap') for item in ranked], 5), signed=False)} / " + f"{percent_text(concentration([item.get('market_cap') for item in ranked], 10), signed=False)}。", + f"- ROE 中位数为 {percent_text(summary.get('roe_median'), signed=False)};市值龙头未必是盈利质量最优,需要结合 ROE 和利润兑现能力交叉判断。", + "", + format_table(headers, table_rows), + "", + "### 龙头梯队", + f"- 第一梯队(市值前三):{format_company_list(top3, 'market_cap', lambda value: billion_yuan_text(value), limit=3)}。", + f"- 第二梯队(市值第 4-8 名):{format_company_list(next5, 'market_cap', lambda value: billion_yuan_text(value), limit=5)}。", + f"- 利润修复较快、可能提升行业话语权的挑战者:{format_company_list(challengers, 'profit_yoy', lambda value: percent_text(value), limit=5)}。", + "", + "### 竞争格局观察", + "- 高集中度行业通常意味着龙头在品牌、渠道或成本端占优,但也意味着估值会更快反映市场共识。", + "- 若 CR5 很高而二线公司仍能保持正的利润同比,通常说明行业景气在龙头之外也有扩散。", + "- 若龙头市值集中但利润修复并未同步集中,往往意味着资金先交易确定性,再等待基本面向二线扩散。", + "", + "*数据来源:RQData,置信度5*", + ] + ) + + +def build_valuation_section(rows: Sequence[Dict[str, Any]], summary: Dict[str, Any]) -> str: + filtered_rows = [item for item in rows if not is_special_treatment(item)] + positive_pe_ranked = ranked_rows(filtered_rows, "pe", reverse=False, require_positive=True) + high_dividend_ranked = ranked_rows(filtered_rows, "dividend_yield", reverse=True) + stressed = [ + item for item in rows + if item.get("pe") is not None and (item["pe"] <= 0 or item["pe"] >= max((summary.get("pe_median") or 0.0) * 2, 60.0)) + ] + headers = ["公司", "PE", "PB", "股息率", "ROE", "净利润同比"] + table_rows = [ + [ + f"{item['name']}
    `{item['order_book_id']}`", + x_text(item.get("pe"), 1), + x_text(item.get("pb")), + percent_text(item.get("dividend_yield"), signed=False), + percent_text(item.get("roe"), signed=False), + percent_text(item.get("profit_yoy")), + ] + for item in positive_pe_ranked[:12] + ] + return "\n".join( + [ + f"- 行业 PE 中位数约为 {x_text(summary.get('pe_median'), 1)},PB 中位数约为 {x_text(summary.get('pb_median'))},股息率中位数约为 {percent_text(summary.get('dividend_median'), signed=False)}。", + f"- 正 PE 样本均值约为 {x_text(summary.get('pe_average'), 1)},说明行业估值分布可能存在被少数高估值公司拉宽的现象。", + "", + format_table(headers, table_rows), + "", + "### 估值与回报观察", + f"- 低估值样本(正 PE 由低到高):{format_company_list(positive_pe_ranked, 'pe', lambda value: x_text(value, 1), limit=5)}。", + f"- 高股息样本:{format_company_list(high_dividend_ranked, 'dividend_yield', lambda value: percent_text(value, signed=False), limit=5)}。", + f"- 高估值或亏损样本:{format_company_list(stressed, 'pe', lambda value: x_text(value, 1), limit=5)}。", + "- 低估值且高 ROE 的公司更容易成为行业中的性价比候选,但若利润同比转负,估值折价也可能有基本面原因。", + "- 高股息率在成熟行业中有参考价值,但仍需结合现金转化率确认分红可持续性。", + "", + "*数据来源:RQData,置信度5*", + ] + ) + + +def build_performance_section(rows: Sequence[Dict[str, Any]], returns: Dict[str, float], summary: Dict[str, Any]) -> str: + ranked = sorted( + [ + {**item, "period_return": returns.get(item["order_book_id"])} + for item in rows + if item["order_book_id"] in returns + ], + key=lambda item: item.get("period_return") or -9999.0, + reverse=True, + ) + laggards = [item for item in list(reversed(ranked)) if not is_special_treatment(item)][:5] + headers = ["公司", "区间收益", "ROE", "PE", "净利润同比", "股息率"] + table_rows = [ + [ + f"{item['name']}
    `{item['order_book_id']}`", + percent_text(item.get("period_return")), + percent_text(item.get("roe"), signed=False), + x_text(item.get("pe"), 1), + percent_text(item.get("profit_yoy")), + percent_text(item.get("dividend_yield"), signed=False), + ] + for item in ranked[:12] + ] + return "\n".join( + [ + f"- 行业成分股区间收益中位数约为 {percent_text(summary.get('sector_return_median'))},基准指数收益约为 {percent_text(summary.get('benchmark_return'))},行业超额收益约为 {percent_text(summary.get('excess_return'))}。", + f"- 上涨家数占比约为 {percent_text(summary.get('breadth'), signed=False)};收益广度能帮助判断行业行情是龙头驱动还是全面扩散。", + "", + format_table(headers, table_rows), + "", + "### 表现观察", + f"- 区间涨幅靠前样本:{format_company_list(ranked, 'period_return', lambda value: percent_text(value), limit=5)}。", + f"- 区间回撤较大样本:{format_company_list(laggards, 'period_return', lambda value: percent_text(value), limit=5)}。", + "- 如果行业收益显著跑赢基准,但上涨广度有限,通常说明资金集中在少数龙头。", + "- 如果收益中位数和上涨广度都不错,说明行业贝塔与个股阿尔法都在改善。", + "- 如果股价表现明显领先而利润同比尚未兑现,往往意味着市场交易的是预期改善而不是当期盈利。", + "", + "*数据来源:RQData,置信度5*", + ] + ) + + +def build_investment_framework_section( + rows: Sequence[Dict[str, Any]], + returns: Dict[str, float], + summary: Dict[str, Any], + external_findings: Sequence[Dict[str, Any]] = (), +) -> str: + candidates = [] + for item in rows: + if is_special_treatment(item): + continue + value_signal = item.get("pe") is not None and summary.get("pe_median") is not None and item["pe"] <= summary["pe_median"] + quality_signal = item.get("roe") is not None and summary.get("roe_median") is not None and item["roe"] >= summary["roe_median"] + growth_signal = (item.get("profit_yoy") or -999) > 0 + yield_signal = item.get("dividend_yield") is not None and summary.get("dividend_median") is not None and item["dividend_yield"] >= summary["dividend_median"] + score = sum([value_signal, quality_signal, growth_signal, yield_signal]) + if score >= 3: + candidates.append({**item, "score": score, "period_return": returns.get(item["order_book_id"])}) + candidates.sort(key=lambda item: (-item["score"], -(item.get("roe") or 0.0), item.get("pe") or 9999.0)) + + defensive = [ + item for item in candidates + if (item.get("dividend_yield") or 0.0) >= (summary.get("dividend_median") or 0.0) and (item.get("roe") or -9999.0) >= (summary.get("roe_median") or -9999.0) + ][:5] + repair = [ + {**item, "period_return": returns.get(item["order_book_id"])} + for item in rows + if not is_special_treatment(item) + if (item.get("profit_yoy") or -9999.0) > 20 + and (returns.get(item["order_book_id"]) is None or returns.get(item["order_book_id"], 9999.0) <= (summary.get("sector_return_median") or 0.0)) + ] + repair = sorted(repair, key=lambda item: item.get("profit_yoy") or -9999.0, reverse=True)[:5] + momentum = [ + {**item, "period_return": returns.get(item["order_book_id"])} + for item in rows + if not is_special_treatment(item) + if returns.get(item["order_book_id"]) is not None + and returns[item["order_book_id"]] > 0 + and (item.get("profit_yoy") or -9999.0) > 0 + ] + momentum = sorted(momentum, key=lambda item: item.get("period_return") or -9999.0, reverse=True)[:5] + + headers = ["公司", "得分", "PE", "ROE", "净利润同比", "股息率", "区间收益"] + table_rows = [ + [ + f"{item['name']}
    `{item['order_book_id']}`", + str(item["score"]), + x_text(item.get("pe"), 1), + percent_text(item.get("roe"), signed=False), + percent_text(item.get("profit_yoy")), + percent_text(item.get("dividend_yield"), signed=False), + percent_text(item.get("period_return")), + ] + for item in candidates[:10] + ] or [["暂无候选", "-", "-", "-", "-", "-", "-"]] + + lines = [ + "- 核心筛选规则:PE 不高于行业中位数、ROE 不低于行业中位数、净利润同比为正、股息率不低于行业中位数;满足 4 项中的至少 3 项进入优先观察名单。", + "", + format_table(headers, table_rows), + "", + "### 投资线索", + ] + if candidates: + for item in candidates[:6]: + lines.append( + f"- **{item['name']}({item['order_book_id']})**:满足 {item['score']} 项条件," + f"PE {x_text(item.get('pe'), 1)}、ROE {percent_text(item.get('roe'), signed=False)}、" + f"净利润同比 {percent_text(item.get('profit_yoy'))}、股息率 {percent_text(item.get('dividend_yield'), signed=False)}。" + ) + else: + lines.append("- 当前没有满足至少 3 项条件的公司,说明行业要么整体估值偏高,要么盈利质量尚未全面改善。") + lines.extend( + [ + "", + "### 分组跟踪", + f"- 防御型配置线索:{format_company_list(defensive, 'dividend_yield', lambda value: percent_text(value, signed=False), limit=5)}。", + f"- 盈利修复但股价尚未充分反映的样本:{format_company_list(repair, 'profit_yoy', lambda value: percent_text(value), limit=5)}。", + f"- 景气与股价共振样本:{format_company_list(momentum, 'period_return', lambda value: percent_text(value), limit=5)}。", + "- 后续跟踪建议优先围绕三类变量展开:利润增速能否延续、ROE 是否稳定高于行业中位数、估值是否在盈利兑现后仍具备吸引力。", + "", + "### 跟踪指标清单", + f"- 基本面指标:营收同比中位数 {percent_text(summary.get('revenue_yoy_median'))}、净利润同比中位数 {percent_text(summary.get('profit_yoy_median'))}、ROE 中位数 {percent_text(summary.get('roe_median'), signed=False)}。", + f"- 交易指标:区间收益中位数 {percent_text(summary.get('sector_return_median'))}、超额收益 {percent_text(summary.get('excess_return'))}、上涨广度 {percent_text(summary.get('breadth'), signed=False)}。", + f"- 估值指标:PE 中位数 {x_text(summary.get('pe_median'), 1)}、PB 中位数 {x_text(summary.get('pb_median'))}、股息率中位数 {percent_text(summary.get('dividend_median'), signed=False)}。", + "- 当基本面改善先于交易指标时,更适合等待验证;当交易指标先于基本面改善时,则要警惕预期交易过满后的回撤压力。", + "", + "*数据来源:RQData,置信度5*", + ] + ) + if external_findings: + lines.extend(["", *build_external_context_block(external_findings[:3], "### 外部线索补充")]) + return "\n".join(lines) + + +def build_risk_section(summary: Dict[str, Any], stats: Dict[str, Any], rows: Sequence[Dict[str, Any]]) -> str: + lines = [] + if stats["covered_size"] < stats["pool_size"]: + lines.append(f"- **样本覆盖风险**:股票池 {stats['pool_size']} 家中仅有 {stats['covered_size']} 家进入主导季度比较口径,行业结论仍有覆盖缺口。") + cr5 = concentration([item.get("market_cap") for item in rows], 5) + if cr5 is not None and cr5 > 70: + lines.append(f"- **龙头集中风险**:CR5 约为 {percent_text(cr5, signed=False)},行业收益和估值容易被少数龙头主导。") + if summary.get("excess_return") is not None and summary["excess_return"] > 15: + lines.append(f"- **预期透支风险**:行业近 180 天相对基准超额收益约为 {percent_text(summary['excess_return'])},短期定价可能已不便宜。") + if summary.get("profit_yoy_median") is not None and summary["profit_yoy_median"] < 0: + lines.append(f"- **盈利承压风险**:净利润同比中位数约为 {percent_text(summary['profit_yoy_median'])},说明行业盈利趋势尚未普遍改善。") + if not lines: + lines.append("- 当前未识别到突出的新增风险,但仍需跟踪主导季度财务兑现、估值位置和行业广度变化。") + lines.append("") + lines.append("*数据来源:RQData,置信度5*") + return "\n".join(lines) + + +def build_appendix(args: argparse.Namespace, sector_definition: Optional[Dict[str, Any]], stats: Dict[str, Any]) -> str: + lines = [ + f"- 报告日期为 {args.report_date},行业名称为 `{args.industry}`。", + f"- 行业样本来自显式股票池,股票池规模 {stats['pool_size']} 家,进入主导季度比较口径 {stats['covered_size']} 家。", + f"- 主导财报季度为 `{stats['dominant_quarter'] or '无数据'}`,同比比较季度为 `{stats['yoy_quarter'] or '无数据'}`;行业同比结论基于该口径计算。", + f"- 当前行业来源为 `{(sector_definition or {}).get('industry_source', '未提供')}`,行业层级为 `{(sector_definition or {}).get('industry_level', '未提供')}`。", + "- 估值和价格指标应使用最近非空因子日;若最近交易日返回空值,需要向前回溯至可用日期。", + "- 股息率原始单位为 bps,报告展示时已换算为百分比。", + "- 本报告旨在提供行业横向比较和跟踪框架,不替代针对单一公司的深度尽调。", + "", + "*数据来源:RQData,置信度5*", + ] + return "\n".join(lines) + + +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() + data_dir = Path(args.data_dir).expanduser() + + sector_definition = read_json_file(data_dir / "sector_definition.json") + 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")) + industry_records = extract_records(read_json_file(data_dir / "industry_map.json")) + historical_financials = extract_records(read_json_file(data_dir / "historical_financials.json")) + latest_financials = extract_records(read_json_file(data_dir / "latest_financials.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")) + dividend_records = extract_records(read_json_file(data_dir / "dividend_yield.json")) + price_records = extract_records(read_json_file(data_dir / "price_period.json")) + benchmark_records = extract_records(read_json_file(data_dir / "benchmark_price.json")) + web_search_records = extract_records(read_json_file(data_dir / "web_search_findings.json")) + validate_web_search_records(web_search_records) + external_findings = extract_external_findings(web_search_records) + + stock_ids = build_stock_pool(stock_pool_records) + if not stock_ids: + raise ValueError("stock_pool.json 中缺少有效股票池。") + + rows, stats = build_sector_rows( + stock_ids, + instrument_records, + industry_records, + latest_financials or historical_financials, + roe_records, + market_cap_records, + pe_records, + pb_records, + dividend_records, + ) + returns = build_return_map(price_records, stock_ids) + benchmark_return = build_benchmark_return(benchmark_records) + summary = sector_summary(rows, returns, benchmark_return) + validate_dataset_quality(stock_ids, rows, returns, summary) + + exec_summary = build_exec_summary(args.industry, stats, summary, rows, external_findings) + sector_scope = build_scope_section(args.industry, sector_definition if isinstance(sector_definition, dict) else None, stats, rows) + sector_state = build_sector_state(args.industry, summary, rows, external_findings) + financial_structure = build_financial_section(rows, summary, stats) + competition = build_competition_section(rows, summary) + valuation = build_valuation_section(rows, summary) + performance = build_performance_section(rows, returns, summary) + investment_framework = build_investment_framework_section(rows, returns, summary, external_findings) + risk_section = build_risk_section(summary, stats, rows) + appendix = build_appendix( + args, + sector_definition if isinstance(sector_definition, dict) else None, + stats, + ) + + template_text = Path(args.template).read_text(encoding="utf-8") + report_text = render_template( + template_text, + { + "REPORT_DATE": args.report_date, + "INDUSTRY_NAME": args.industry, + "EXEC_SUMMARY": exec_summary, + "SECTOR_SCOPE": sector_scope, + "SECTOR_STATE": sector_state, + "FINANCIAL_STRUCTURE": financial_structure, + "COMPETITION": competition, + "VALUATION": valuation, + "PERFORMANCE": performance, + "INVESTMENT_FRAMEWORK": investment_framework, + "RISK_SECTION": risk_section, + "APPENDIX": appendix, + }, + ) + + output_path = Path(args.output).expanduser() if args.output else data_dir / f"sector_overview_{args.industry}_{args.report_date}.md" + 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() diff --git a/.cursor/skills/thesis-tracker/SKILL.md b/.cursor/skills/thesis-tracker/SKILL.md new file mode 100644 index 000000000..11f287e0e --- /dev/null +++ b/.cursor/skills/thesis-tracker/SKILL.md @@ -0,0 +1,488 @@ +--- +name: rq-thesis-tracker +description: | + 创建模板驱动的投资论文跟踪报告,系统化跟踪核心观点、关键支柱、资本回报、催化剂和风险信号。 + `RQData CLI` 负责财务、价格、估值、公告、分红和股东结构等结构化主数据;若需要补充公司新闻、管理层变化、行业趋势、竞争格局或分析师观点,可额外使用 `web_search` 获取实时信息,并先落为结构化 JSON,再由 thesis-tracker/scripts/generate_report.py 以客户可读方式纳入最终正文。 + + 务必使用此技能当用户: + - 明确请求投资论文、thesis、投资逻辑、论文跟踪 + - 想建立或更新投资框架、验证关键假设、更新观点 + - 需要追踪催化剂、里程碑、资本回报或信念度变化 + - 需要把既有 thesis 和最新数据做系统化对照 + + 不适用场景: + - 首次覆盖深度研究 -> initiating-coverage + - 财报发布后的单次点评 -> earnings-analysis + - 只需简单投资建议且不需要完整跟踪报告 +compatibility: "Requires rqdata CLI, Bash, python3. Report structure is managed by thesis-tracker/assets/template.md." +--- + +# RQ 股票研究 - 投资论文跟踪 + +## 核心原则 + +- 所有内容必须遵循三阶段流程:数据采集 -> 报告生成 -> HTML 渲染 +- `assets/template.md` 是唯一报告模板来源;Python 只做数据归一化、指标计算、占位符填充和结构校验 +- skill 必须自包含,不能依赖仓库级 `utils` +- 不能保留 `[XX]`、`[需要补充]`、`[原因1]` 这类占位文本 +- 若提供 `thesis_definition.json`,报告必须优先按用户 thesis 验证;若未提供,则明确说明使用默认财务质量框架 +- Thesis 跟踪必须可验证、可反驳;每条关键支柱都应对应可观测指标和反证条件 +- 客户稿不得暴露文件名、字段名、`LLM`、`skill` 或内部 workflow 术语 +- 缺少数据时必须明确写“无数据 / 无事件 / 未验证 / 未提供 thesis 定义”,不能留空 + +## 数据源分工 + +### `RQData CLI` 负责 + +- 财务、ROE、价格、估值、分红和股东结构 +- 公司公告及已实现催化剂 +- Thesis 跟踪的结构化主数据和量化验证读数 + +### `web_search` 负责 + +- 公司最新新闻、管理层变化、治理动态 +- 行业趋势、竞争对手变化、供需链条和主题演化 +- 分析师观点变化或网络搜索结果中的预期线索 + +### `web_search` 禁止替代的内容 + +- 财务、估值、价格、分红、股东结构和公告主数据 +- Thesis 支柱是否通过的核心量化判断 +- 任何本应由 RQData 提供的结构化金融数据 + +## `web_search` 使用规则 + +详细字段、来源等级、落盘示例和 fallback 规则见 [references/web_search.md](references/web_search.md)。 + +允许补充的内容: + +- 公司动态、管理层变化、治理事件 +- 行业趋势、竞争格局、分析师观点变化 +- RQData 无法直接提供的实时定性语境 + +落盘要求: + +- 所有网络搜索结果必须先写入 `web_search_findings.json` +- 只写结构化记录,不把搜索草稿或内部笔记直接塞进报告 +- 若未提供该文件,报告仍可交付,但相关网络搜索结果部分必须保持“未验证”边界 +- 即便提供了 `web_search_findings.json`,它也只能补充 thesis 验证和风险语境,不能直接改写量化结论 + +## 硬性规则 + +以下任一条违反,视为输出失败: + +- `[MUST-1]` 先完整收集所有数据,再开始分析和更新 thesis,禁止边收集边写结论 +- `[MUST-2]` 价格、财务、估值、公告、分红和股东结构必须来自 `RQData CLI` +- `[MUST-3]` 公司新闻、管理层变化和行业趋势必须通过 `web_search` 获取实时信息,不能依赖训练记忆 +- `[MUST-4]` Thesis 必须可证伪;每条支柱都必须有明确的验证规则和反证条件 +- `[MUST-5]` 反证证据必须与支持证据同等严格记录,不能只保留有利信息 +- `[MUST-6]` 每个关键数据点或关键结论都要标 `数据来源:XXX,置信度X` +- `[MUST-7]` 客户稿不得暴露 `LLM`、`skill`、文件名、JSON 字段名或内部 workflow 术语 +- `[MUST-8]` 低置信度网络搜索结果不能单独触发核心 thesis 改写 +- `[MUST-9]` 若高优先级公告存在 `announcement_link`,正文必须保留原文链接 + +## 确信度评级 + +- `5`:RQData CLI、交易所公告、上市公司官网、官方监管披露 +- `4`:政府 / 监管 / 行业协会 / 官方机构、权威财经媒体 +- `3`:一般新闻源,但来源清晰且与其他来源一致 +- `2`:单一来源、细节不完整、时点未充分验证 +- `1`:推断、估算、未验证信息 + +使用规则: + +- 混合结论的置信度取关键来源中的最低等级 +- 低置信度信息只能作为观察线索,不能单独推翻或强化核心 thesis +- 推断类文字不得标成高置信度 + +## 图表 / 图片需求 + +当前实现以表格和结构化追踪说明完成最小可交付版本,但 thesis-tracker 仍必须明确证据与反证展示要求。 + +- 图表/表格名称:Thesis 支柱证据表 +- 目的:逐条展示支柱、当前读数、验证规则与状态 +- 使用的数据文件:`thesis_definition.json`、`latest_financials.json`、`historical_financials.json`、`roe.json` +- 关键字段:`pillars[*]`、`revenue`、`net_profit`、`cash_from_operating_activities`、`return_on_equity_weighted_average` +- 建议形式:表格 +- 回答问题:核心 thesis 当前还有哪些支柱成立 +- 放置位置:`## 关键支柱验证` +- 若图表缺失:不得缺席,必须以表格保留 + +- 图表/表格名称:反证条件表 +- 目的:明确每条支柱的失效条件和下一步观察重点 +- 使用的数据文件:`thesis_definition.json` +- 关键字段:`pillars[*].falsifier` +- 建议形式:表格或条目列表 +- 回答问题:哪些事实会直接削弱或推翻 thesis +- 放置位置:`## 关键支柱验证` +- 若图表缺失:必须以条目列表降级 + +- 图表/表格名称:催化剂跟踪表 +- 目的:区分已实现催化剂、待兑现催化剂和网络搜索结果验证线索 +- 使用的数据文件:`announcement_raw.json`、`dividend.json`、`web_search_findings.json` +- 关键字段:`title`、`info_date`、`announcement_link`、`expected_window`、`why_relevant` +- 建议形式:日历表或追踪表 +- 回答问题:后续哪些事件最可能验证或证伪 thesis +- 放置位置:`## 催化剂跟踪` +- 若图表缺失:必须保留结构化追踪表 + +## 目标产出 + +- 报告长度:5-8 页 +- 目标字数:2,000-3,000 字 +- 输出文件: + - Markdown 报告 + - HTML 报告(若本地已安装渲染器) +- 输出目录必须由 `--data-dir` / `--output` 指定,不能写死固定路径 + +## 目录结构 + +```text +thesis-tracker/ +├── SKILL.md +├── scripts/ +│ └── generate_report.py +├── assets/ +│ └── template.md +└── references/ + ├── data_contract.md + └── web_search.md +``` + +## 输入文件契约 + +原始数据目录由 `--data-dir` 指定,脚本会按下列文件名查找输入: + +- `thesis_definition.json`:可选,自定义 thesis 定义 +- `instrument_meta.json` +- `latest_financials.json` +- `historical_financials.json` +- `roe.json` +- `price_6m.json` +- `hs300_6m.json` +- `pe_ratio.json` +- `pb_ratio.json` +- `dividend.json` +- `shareholder_top10.json` +- `announcement_raw.json` +- `web_search_findings.json`(可选) + +完整字段说明见 [references/data_contract.md](references/data_contract.md)。 + +## 工作流 + +### 步骤 1:准备参数 + +```bash +REPORT_DATE="${REPORT_DATE:-$(date +%F)}" +ORDER_BOOK_ID="${ORDER_BOOK_ID:-601318.XSHG}" +PRICE_START_DATE="$(python3 - <=", + "threshold": 1.0, + "falsifier": "若收入同比连续跌破 1%,则该支柱失效" + }, + { + "name": "归母净利润保持增长", + "metric": "latest_net_profit_yoy_pct", + "operator": ">=", + "threshold": 5.0, + "falsifier": "若利润同比回落至 5% 以下,则 thesis 需要下修" + }, + { + "name": "ROE维持双位数", + "metric": "latest_roe", + "operator": ">=", + "threshold": 10.0 + }, + { + "name": "现金创造强于利润", + "metric": "latest_cash_profit_ratio", + "operator": ">=", + "threshold": 1.0 + } + ], + "planned_catalysts": [ + { + "title": "年度利润分配执行", + "expected_window": "2026-04至2026-06", + "expected_impact": "验证资本回报改善是否兑现" + } + ], + "risk_items": [ + { + "title": "资本市场波动拖累投资收益", + "initial_assessment": "中", + "monitor": "关注利润弹性与估值波动", + "response": "跟踪利润兑现与估值再定价节奏" + } + ] +} +``` + +如果没有该文件,脚本会自动退化为“默认财务质量框架”,并在报告中明确标注。 + +### 步骤 3:采集结构化主数据 + +```bash +mkdir -p "$DATA_DIR" + +rqdata stock cn instruments --payload "{ + \"order_book_ids\": [\"$ORDER_BOOK_ID\"] +}" --format json > "$DATA_DIR/instrument_meta.json" + +rqdata stock cn financial --payload "{ + \"order_book_ids\": [\"$ORDER_BOOK_ID\"], + \"fields\": [\"revenue\", \"net_profit\", \"cash_from_operating_activities\"], + \"start_quarter\": \"$HISTORY_START_QUARTER\", + \"end_quarter\": \"$HISTORY_END_QUARTER\", + \"statements\": \"all\" +}" --format json > "$DATA_DIR/historical_financials.json" + +rqdata stock cn financial --payload "{ + \"order_book_ids\": [\"$ORDER_BOOK_ID\"], + \"fields\": [\"revenue\", \"net_profit\", \"cash_from_operating_activities\"], + \"start_quarter\": \"$HISTORY_START_QUARTER\", + \"end_quarter\": \"$HISTORY_END_QUARTER\", + \"statements\": \"all\" +}" --format json > "$DATA_DIR/latest_financials.json" + +rqdata stock cn financial-indicator --payload "{ + \"order_book_ids\": [\"$ORDER_BOOK_ID\"], + \"factor\": \"return_on_equity_weighted_average\", + \"start_date\": \"$ROE_START_DATE\", + \"end_date\": \"$REPORT_DATE\" +}" --format json > "$DATA_DIR/roe.json" + +rqdata stock cn financial-indicator --payload "{ + \"order_book_ids\": [\"$ORDER_BOOK_ID\"], + \"factor\": \"pe_ratio\", + \"start_date\": \"$ROE_START_DATE\", + \"end_date\": \"$REPORT_DATE\" +}" --format json > "$DATA_DIR/pe_ratio.json" + +rqdata stock cn financial-indicator --payload "{ + \"order_book_ids\": [\"$ORDER_BOOK_ID\"], + \"factor\": \"pb_ratio\", + \"start_date\": \"$ROE_START_DATE\", + \"end_date\": \"$REPORT_DATE\" +}" --format json > "$DATA_DIR/pb_ratio.json" + +rqdata stock cn price --payload "{ + \"order_book_ids\": [\"$ORDER_BOOK_ID\"], + \"start_date\": \"$PRICE_START_DATE\", + \"end_date\": \"$REPORT_DATE\", + \"fields\": [\"close\", \"total_turnover\"], + \"adjust_type\": \"post\" +}" --format json > "$DATA_DIR/price_6m.json" + +rqdata index price --payload "{ + \"order_book_ids\": [\"000300.XSHG\"], + \"start_date\": \"$PRICE_START_DATE\", + \"end_date\": \"$REPORT_DATE\", + \"fields\": [\"close\"] +}" --format json > "$DATA_DIR/hs300_6m.json" + +rqdata stock cn dividend --payload "{ + \"order_book_ids\": [\"$ORDER_BOOK_ID\"], + \"start_date\": \"$DIVIDEND_START_DATE\", + \"end_date\": \"$REPORT_DATE\" +}" --format json > "$DATA_DIR/dividend.json" + +rqdata stock cn shareholder-top10 --payload "{ + \"order_book_ids\": [\"$ORDER_BOOK_ID\"], + \"start_date\": \"$DIVIDEND_START_DATE\", + \"end_date\": \"$REPORT_DATE\" +}" --format json > "$DATA_DIR/shareholder_top10.json" + +rqdata stock cn announcement --payload "{ + \"order_book_ids\": [\"$ORDER_BOOK_ID\"], + \"start_date\": \"$ANNOUNCEMENT_START_DATE\", + \"end_date\": \"$REPORT_DATE\" +}" --format json > "$DATA_DIR/announcement_raw.json" +``` + +说明: + +- `financial-indicator` 使用 `factor` 参数,不是 `fields` +- `roe.json` 建议抓一个较宽的日期区间,脚本会自动选最新可用值 +- `historical_financials.json` 和 `latest_financials.json` 允许复用同一份原始数据,脚本内部会自动抽取最新报告期 +- `shareholder_top10.json` 建议至少覆盖最近 4-6 个季度,便于观察稳定性 +- `announcement_raw.json` 建议至少回看 30 天,用于识别最近已实现催化剂 + +### 步骤 3.5:可选的公司 / 管理层 / 行业语境补充 + +当用户需要更完整的 thesis 验证背景时,可执行该步骤。 + +- 使用 `web_search` 补充公司新闻、管理层变化、行业趋势、竞争格局或分析师观点变化 +- 结果必须写入 `web_search_findings.json` +- 这类结果只补充 thesis 验证、催化剂跟踪和风险监控语境,不能替代量化主数据 + +### 步骤 4:生成 Markdown 报告 + +```bash +python3 thesis-tracker/scripts/generate_report.py \ + --stock "$ORDER_BOOK_ID" \ + --data-dir "$DATA_DIR" \ + --report-date "$REPORT_DATE" \ + --output "$OUTPUT_MD" +``` + +常用参数: + +- `--stock`:股票代码 +- `--company`:公司名称,可选;若缺失则优先从 `instrument_meta.json` 读取 +- `--data-dir`:原始 JSON 数据目录 +- `--report-date`:报告日期,默认当天 +- `--output`:输出 Markdown 路径 +- `--template`:自定义模板路径,默认 `thesis-tracker/assets/template.md` +- `--no-render`:不生成 HTML + +### 步骤 5:渲染 HTML + +脚本会优先尝试调用本地安装的 `rq-report-renderer`,若未安装则回退到仓库内 `report-renderer/scripts/render_report.py`;两者都不可用时保留 Markdown 并打印警告。 + +## 阶段门控 + +### Gate 1:Thesis 主定义与量化主数据齐备 + +- 股票、财务、价格、估值、公告、分红和股东结构主数据存在 +- 若用户提供自定义 thesis,其关键支柱和目标价可解析 +- 即使没有网络搜索结果,报告也能完成基础 thesis 跟踪 + +### Gate 2:支柱验证与反证条件完成 + +- 每条支柱都能映射到真实指标 +- 每条支柱都有验证规则和反证条件 +- 支持证据与反证线索都已进入正文 + +### Gate 3:可选网络搜索结果完成 + +- 若启用网络搜索结果补充,`web_search_findings.json` 已落盘 +- 字段完整、来源等级可解释 +- 网络搜索结果仅用于公司 / 管理层 / 行业 / 竞争 / 分析师跟踪信息 + +### Gate 4:成稿完成 + +- Markdown 已生成 +- 若本地渲染器存在,HTML 已生成 +- 客户稿不暴露内部术语 +- 长度、章节、来源标注和 thesis 追踪表均达标 + +## 模板规则 + +- 报告必须严格基于 [template.md](assets/template.md) 生成 +- 占位符采用 `[[TOKEN]]` 语法,不使用 Jinja +- 当前模板仅允许以下占位符: + - `[[REPORT_DATE]]` + - `[[AS_OF_TIME]]` + - `[[COMPANY_NAME]]` + - `[[STOCK_CODE]]` + - `[[THESIS_SOURCE]]` + - `[[EXEC_SUMMARY]]` + - `[[THESIS_SNAPSHOT]]` + - `[[PILLAR_VERIFICATION]]` + - `[[MARKET_PERFORMANCE]]` + - `[[CAPITAL_RETURN]]` + - `[[CATALYST_TRACKING]]` + - `[[RISK_MONITORING]]` + - `[[UPDATE_LOG]]` + - `[[APPENDIX]]` + +## 报告质量要求 + +- 完整包含模板中的主章节 +- 论文快照必须落到 thesis 名称、当前价/目标价、信念度和仓位/周期等交付层信息 +- 关键支柱必须引用真实指标,不得保留空白观点 +- 每条支柱都必须能回答 4 个问题: + - 当前读数是什么 + - 验证规则是什么 + - 反证条件是什么 + - 当前状态是通过还是未通过 +- 若无自定义 thesis 文件,必须明确写明“采用默认财务质量框架” +- 催化剂和风险必须优先引用真实公告、分红和价格表现 +- 若启用 `web_search`,正文必须真实吸收公司 / 管理层 / 行业 / 竞争 / 分析师语境,而不是只多一个 sidecar JSON +- 高优先级催化剂若存在 `announcement_link`,正文必须保留原文链接 +- 风险监控不能只剩几条泛化 bullet,应至少包含风险矩阵或风险详解 +- 不得残留 `[XX]`、`[原因1]`、`[催化剂1]`、旧路径或内部字段名 + +## 阶段验收清单 + +- [ ] Markdown 文件存在 +- [ ] 若本地渲染器存在则 HTML 文件存在 +- [ ] 模板占位符无残留 +- [ ] 主章节完整 +- [ ] 报告达到 5-8 页最低可交付标准 +- [ ] 关键数据点与关键结论带 `数据来源:XXX,置信度X` +- [ ] Thesis 支柱包含验证规则和反证条件 +- [ ] 若使用 `web_search`,其内容已真正进入最终报告且未越权替代 RQData 主数据 +- [ ] 高优先级催化剂保留原文链接 + +## 常见错误 + +- 直接在 Python 中写死“核心观点 / 催化剂 / 风险”占位文字 +- 把 `financial-indicator` 错当成 `fields` 接口使用 +- 不去重 `financial` 的复权 / 重述记录,导致同一季度重复计算 +- 把低信号公告当作核心催化剂 +- 只记录支持 thesis 的证据,不记录反证条件和失效线索 +- 把网络搜索结果直接写成核心结论,却没有结构化来源落盘 +- 继续依赖旧版 `~/.claude/skills/...` 路径来组织 skill 内部文件 diff --git a/.cursor/skills/thesis-tracker/assets/template.md b/.cursor/skills/thesis-tracker/assets/template.md new file mode 100644 index 000000000..626d4c749 --- /dev/null +++ b/.cursor/skills/thesis-tracker/assets/template.md @@ -0,0 +1,33 @@ +# 投资论文跟踪报告 + +报告日期:[[REPORT_DATE]] +信息截面:[[AS_OF_TIME]] +公司:[[COMPANY_NAME]]([[STOCK_CODE]]) +逻辑定义来源:[[THESIS_SOURCE]] + +## 执行摘要 +[[EXEC_SUMMARY]] + +## 论文快照 +[[THESIS_SNAPSHOT]] + +## 关键支柱验证 +[[PILLAR_VERIFICATION]] + +## 股价与相对收益回顾 +[[MARKET_PERFORMANCE]] + +## 资本回报与股东结构 +[[CAPITAL_RETURN]] + +## 催化剂跟踪 +[[CATALYST_TRACKING]] + +## 风险监控 +[[RISK_MONITORING]] + +## 更新日志 +[[UPDATE_LOG]] + +## 附录:口径说明 +[[APPENDIX]] diff --git a/.cursor/skills/thesis-tracker/references/data_contract.md b/.cursor/skills/thesis-tracker/references/data_contract.md new file mode 100644 index 000000000..a81a4f778 --- /dev/null +++ b/.cursor/skills/thesis-tracker/references/data_contract.md @@ -0,0 +1,234 @@ +# Thesis Tracker Data Contract + +## Raw Inputs + +The generator looks for these files inside `--data-dir`: + +- `thesis_definition.json` (optional) +- `instrument_meta.json` +- `latest_financials.json` +- `historical_financials.json` +- `roe.json` +- `price_6m.json` +- `hs300_6m.json` +- `pe_ratio.json` +- `pb_ratio.json` +- `dividend.json` +- `shareholder_top10.json` +- `announcement_raw.json` +- `web_search_findings.json` (optional) + +Each file may be: + +- `{ "data": [...] }` +- `{ "data": { ... } }` +- `[...]` +- `{ ... }` + +## Thesis Definition + +`thesis_definition.json` is optional. When provided, it may include: + +- `thesis_name` +- `core_view` +- `confidence_label` +- `holding_period` +- `position_date` +- `target_price` + - `value` + - `currency` +- `pillars` + - `name` + - `metric` + - `operator` + - `threshold` + - `falsifier` (optional) + - `rationale` (optional) +- `planned_catalysts` + - `title` + - `expected_window` + - `expected_impact` (optional) +- `risk_items` + - `title` + - `initial_assessment` (optional) + - `monitor` + - `response` (optional) + +Additional optional fields: + +- `current_position` / `position` +- `direction` + +Supported metric keys for custom pillars: + +- `latest_revenue_yoy_pct` +- `latest_net_profit_yoy_pct` +- `latest_cash_profit_ratio` +- `latest_roe` +- `price_return_6m_pct` +- `excess_return_6m_pct` +- `top1_holder_pct` +- `top10_holder_pct` + +Supported operators: + +- `>` +- `>=` +- `<` +- `<=` +- `==` + +If `falsifier` is omitted, the generator will derive a client-readable default refutation condition from the metric rule. + +## Common Identifier Fields + +Ticker fields: + +- `order_book_id` +- `ticker` +- `stock_code` +- `symbol` +- `code` + +Company name fields: + +- `display_name` +- `name` +- `stock_name` +- `company_name` +- `symbol_name` +- `symbol` + +## Financial Records + +`latest_financials.json` and `historical_financials.json` are expected to include: + +- `order_book_id` +- `quarter` +- `info_date` +- `revenue` +- `net_profit` +- `cash_from_operating_activities` + +The generator deduplicates records by quarter and keeps the latest disclosed version. + +## Financial Indicator Records + +`roe.json` is expected to come from `stock cn financial-indicator` using: + +- `factor = return_on_equity_weighted_average` + +Returned fields typically include: + +- `order_book_id` +- `date` +- `return_on_equity_weighted_average` + +`pe_ratio.json` / `pb_ratio.json` should also come from `stock cn financial-indicator` using: + +- `factor = pe_ratio` +- `factor = pb_ratio` + +Returned fields typically include: + +- `order_book_id` +- `date` +- `pe_ratio` or `pb_ratio` + +## Price Records + +`price_6m.json` should include: + +- `order_book_id` +- `datetime` +- `close` +- `total_turnover` (optional) + +`hs300_6m.json` should include: + +- `order_book_id` +- `datetime` +- `close` + +## Dividend Records + +`dividend.json` may include: + +- `quarter` +- `advance_date` +- `declaration_announcement_date` +- `book_closure_date` +- `ex_dividend_date` +- `payable_date` +- `dividend_cash_before_tax` +- `round_lot` + +## Shareholder Records + +`shareholder_top10.json` may include: + +- `end_date` +- `info_date` +- `rank` +- `shareholder_name` +- `hold_percent_total` +- `hold_percent_float` + +The generator summarizes the latest disclosure period and compares concentration with the previous period when possible. + +## Announcement Records + +`announcement_raw.json` may include: + +- `info_date` +- `title` +- `info_type` +- `media` +- `announcement_link` + +Low-signal governance boilerplate is filtered out before catalyst classification. +When `announcement_link` exists, material realized catalysts should keep the source link so later workflows can read the original PDF/HTML. + +## External Findings + +`web_search_findings.json` is optional. When provided, each record should include: + +- `query` +- `source_name` +- `source_type` +- `title` +- `url` +- `published_at` +- `retrieved_at` +- `summary` +- `why_relevant` +- `confidence` +- `finding_type` + +Allowed `source_type`: + +- `official` +- `government` +- `association` +- `authoritative_media` +- `general_news` +- `inference` + +Allowed `finding_type`: + +- `company_news` +- `management_change` +- `industry_trend` +- `competition_context` +- `analyst_view` + +Confidence ceiling by `source_type`: + +- `official`: `5` +- `government`: `4` +- `association`: `4` +- `authoritative_media`: `4` +- `general_news`: `3` +- `inference`: `1` + +The generator uses external findings only as supplementary thesis-validation context. Low-confidence findings should be treated as watch items rather than thesis-changing facts. diff --git a/.cursor/skills/thesis-tracker/references/web_search.md b/.cursor/skills/thesis-tracker/references/web_search.md new file mode 100644 index 000000000..27c94e9de --- /dev/null +++ b/.cursor/skills/thesis-tracker/references/web_search.md @@ -0,0 +1,99 @@ +# Thesis Tracker Web Search Reference + +## Purpose + +Use `web_search` only to supplement company news, management changes, industry trend, competition, and analyst-view context that `RQData CLI` does not directly provide for a thesis-tracker report. + +## Allowed Coverage + +- Company news and major operating developments +- Management changes, governance developments, and organizational updates +- Industry trend and demand-cycle context +- Competitive structure and peer positioning context +- Analyst-view changes or external expectation shifts + +## Prohibited Usage + +- Do not replace prices, financials, valuation factors, dividends, shareholder structure, or announcements +- Do not fabricate company disclosures, target prices, or hard financial facts +- Do not let low-confidence external information rewrite the core thesis on its own + +## Required Output File + +All external findings must be written to `web_search_findings.json`. + +Each record must contain: + +- `query` +- `source_name` +- `source_type` +- `title` +- `url` +- `published_at` +- `retrieved_at` +- `summary` +- `why_relevant` +- `confidence` +- `finding_type` + +Recommended fields: + +- `subject` +- `related_entities` + +## Allowed `finding_type` + +- `company_news` +- `management_change` +- `industry_trend` +- `competition_context` +- `analyst_view` + +## Source Types And Confidence Ceiling + +- `official`: max confidence `5` +- `government`: max confidence `4` +- `association`: max confidence `4` +- `authoritative_media`: max confidence `4` +- `general_news`: max confidence `3` +- `inference`: max confidence `1` + +## Search Workflow + +1. Confirm the needed information is not directly available from `RQData CLI`. +2. Prefer official, exchange, company, regulator, and primary-media sources first. +3. Save the findings into `web_search_findings.json` with structured metadata. +4. Keep the summary factual and keep the relevance note tied to thesis validation or falsification. +5. Use the findings only as supplementary evidence for catalyst tracking, risk monitoring, and thesis context. + +## 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 the related context as unavailable or unverified + - keep the report at the RQData-driven thesis-tracking level + +## Example + +```json +{ + "data": [ + { + "query": "中国平安 管理层 2026-04", + "source_name": "中国平安", + "source_type": "official", + "title": "公司发布高管调整公告", + "url": "https://www.example.com/pingan", + "published_at": "2026-04-07", + "retrieved_at": "2026-04-08", + "summary": "公司披露管理层分工调整与组织安排。", + "why_relevant": "可用于验证管理层执行力与战略推进节奏是否发生变化。", + "confidence": 5, + "finding_type": "management_change", + "subject": "管理层调整" + } + ] +} +``` diff --git a/.cursor/skills/thesis-tracker/scripts/generate_report.py b/.cursor/skills/thesis-tracker/scripts/generate_report.py new file mode 100644 index 000000000..aa105af7d --- /dev/null +++ b/.cursor/skills/thesis-tracker/scripts/generate_report.py @@ -0,0 +1,1283 @@ +#!/usr/bin/env python3 +"""Template-driven thesis tracker report generator.""" + +from __future__ import annotations + +import argparse +import json +import re +import shutil +import subprocess +from dataclasses import dataclass +from datetime import date, datetime +from pathlib import Path +from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple + + +TOKEN_RE = re.compile(r"\[\[([A-Z0-9_]+)\]\]") +REQUIRED_HEADINGS = [ + "# 投资论文跟踪报告", + "## 执行摘要", + "## 论文快照", + "## 关键支柱验证", + "## 股价与相对收益回顾", + "## 资本回报与股东结构", + "## 催化剂跟踪", + "## 风险监控", + "## 更新日志", + "## 附录:口径说明", +] +WEB_SOURCE_CONFIDENCE_CEILING = { + "official": 5, + "government": 4, + "association": 4, + "authoritative_media": 4, + "general_news": 3, + "inference": 1, +} +WEB_ALLOWED_FINDING_TYPES = { + "company_news", + "management_change", + "industry_trend", + "competition_context", + "analyst_view", +} + +TICKER_FIELDS = ("order_book_id", "ticker", "stock_code", "symbol", "code") +NAME_FIELDS = ("display_name", "name", "stock_name", "company_name", "symbol_name", "symbol") + + +@dataclass +class PillarResult: + name: str + metric: str + actual_value: Optional[float] + actual_text: str + expectation_text: str + falsifier_text: str + passed: bool + rationale: str + + +@dataclass +class CatalystItem: + title: str + event_date: date + category: str + priority: int + announcement_link: Optional[str] + media: str + info_type: str + + +@dataclass +class ExternalFinding: + source_name: str + title: str + published_at: date + summary: str + why_relevant: str + confidence: int + finding_type: str + + +def parse_args() -> argparse.Namespace: + skill_dir = Path(__file__).resolve().parent.parent + parser = argparse.ArgumentParser(description="生成模板驱动的投资论文跟踪报告") + parser.add_argument("--stock", required=True, help="股票代码") + parser.add_argument("--company", 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="输出 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 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 + + candidates = [ + "%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", + ] + for fmt in candidates: + try: + return datetime.strptime(text, fmt).date() + except ValueError: + continue + + text = text[:10] + try: + return datetime.strptime(text, "%Y-%m-%d").date() + except ValueError: + return None + + +def parse_iso_datetime(value: Any) -> Optional[datetime]: + if value in (None, "", "null"): + return None + if isinstance(value, datetime): + return value + if isinstance(value, date): + return datetime.combine(value, datetime.min.time()) + + text = str(value).strip() + if not text: + return None + + candidates = [ + "%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", + "%Y-%m-%d", + "%Y/%m/%d", + ] + for fmt in candidates: + try: + return datetime.strptime(text, fmt) + except ValueError: + continue + return None + + +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 float_or_none(value: Any) -> Optional[float]: + if value in (None, "", "null"): + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + +def normalize_link(value: Any) -> Optional[str]: + if value in (None, "", "null"): + return None + text = str(value).strip() + return text or None + + +def clean_text(value: Any) -> str: + return re.sub(r"\s+", " ", str(value or "")).strip() + + +def validate_web_search_records(records: Sequence[Any]) -> None: + if not records: + return + required_fields = { + "query", + "source_name", + "source_type", + "title", + "url", + "published_at", + "retrieved_at", + "summary", + "why_relevant", + "confidence", + "finding_type", + } + issues: List[str] = [] + for idx, item in enumerate(records, start=1): + if not isinstance(item, dict): + issues.append(f"第 {idx} 条网络搜索结果记录不是对象") + continue + missing = [field for field in required_fields if item.get(field) in (None, "", "null")] + if missing: + issues.append(f"第 {idx} 条网络搜索结果记录缺少字段:{', '.join(missing)}") + source_type = str(item.get("source_type") or "").strip() + if source_type not in WEB_SOURCE_CONFIDENCE_CEILING: + issues.append(f"第 {idx} 条网络搜索结果记录来源类型非法:{source_type or '空'}") + confidence = float_or_none(item.get("confidence")) + ceiling = WEB_SOURCE_CONFIDENCE_CEILING.get(source_type) + if confidence is None: + issues.append(f"第 {idx} 条网络搜索结果记录缺少置信度") + elif ceiling is not None and confidence > ceiling: + issues.append(f"第 {idx} 条网络搜索结果记录置信度 {confidence:g} 超过来源上限 {ceiling}") + finding_type = str(item.get("finding_type") or "").strip() + if finding_type not in WEB_ALLOWED_FINDING_TYPES: + issues.append(f"第 {idx} 条网络搜索结果记录 finding_type 非法:{finding_type or '空'}") + if issues: + raise ValueError("网络搜索结果校验失败:" + ";".join(issues)) + + +def extract_external_findings(records: Sequence[Any]) -> List[ExternalFinding]: + findings: List[ExternalFinding] = [] + for item in records: + if not isinstance(item, dict): + continue + published_at = parse_iso_date(item.get("published_at")) + if published_at is None: + continue + findings.append( + ExternalFinding( + source_name=str(item.get("source_name") or "网络搜索来源").strip(), + title=clean_text(item.get("title")), + published_at=published_at, + summary=clean_text(item.get("summary")).rstrip("。;;!!??"), + why_relevant=clean_text(item.get("why_relevant")).rstrip("。;;!!??"), + confidence=int(float_or_none(item.get("confidence")) or 0), + finding_type=str(item.get("finding_type") or "").strip(), + ) + ) + findings.sort(key=lambda item: (item.published_at, item.confidence), reverse=True) + return findings + + +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 ratio_text(value: Optional[float], digits: int = 2) -> str: + if value is None: + return "无数据" + return f"{value:.{digits}f}x" + + +def yuan_text(value: Optional[float]) -> str: + if value is None: + return "无数据" + return f"{value / 1e8:.2f}亿元" + + +def price_text(value: Optional[float]) -> str: + if value is None: + return "无数据" + return f"{value:.2f}元" + + +def format_link_markdown(url: Optional[str], label: str = "原文") -> str: + if not url: + return "" + return f"[{label}]({url})" + + +def choose_latest_by_date(records: Iterable[Dict[str, Any]], date_fields: Sequence[str]) -> Optional[Dict[str, Any]]: + best: Optional[Tuple[date, Dict[str, Any]]] = None + for record in records: + event_date = None + for field in date_fields: + event_date = parse_iso_date(record.get(field)) + if event_date: + break + if event_date is None: + continue + if best is None or event_date > best[0]: + best = (event_date, record) + return best[1] if best else None + + +def dedupe_financial_records(records: List[Any], stock: str, report_date: date) -> List[Dict[str, Any]]: + deduped: Dict[str, Dict[str, Any]] = {} + for item in records: + if not isinstance(item, dict): + continue + if normalize_ticker(item) != stock: + continue + item_date = parse_iso_date(item.get("info_date")) + if item_date and item_date > report_date: + continue + quarter = str(item.get("quarter") or "") + if not quarter: + continue + current = deduped.get(quarter) + current_date = parse_iso_date(current.get("info_date")) if current else None + if current is None or (item_date and (current_date is None or item_date >= current_date)): + deduped[quarter] = item + return [deduped[key] for key in sorted(deduped.keys())] + + +def find_yoy_record(records: Sequence[Dict[str, Any]], latest_quarter: str) -> Optional[Dict[str, Any]]: + match = re.match(r"^(\d{4})q([1-4])$", latest_quarter) + if not match: + return None + yoy_quarter = f"{int(match.group(1)) - 1}q{match.group(2)}" + for record in records: + if record.get("quarter") == yoy_quarter: + return record + return None + + +def load_thesis_definition(path: Path) -> Optional[Dict[str, Any]]: + payload = read_json_file(path) + if payload is None: + return None + if isinstance(payload, dict): + return payload + return None + + +def latest_roe_value(records: List[Any], stock: str) -> Optional[float]: + best_date: Optional[date] = None + best_value: Optional[float] = None + for item in records: + if not isinstance(item, dict) or normalize_ticker(item) != stock: + continue + event_date = parse_iso_date(item.get("date")) + value = float_or_none(item.get("return_on_equity_weighted_average")) + if event_date is None or value is None: + continue + if best_date is None or event_date >= best_date: + best_date = event_date + best_value = value + return best_value + + +def latest_factor_value(records: List[Any], stock: str, field: str, report_date: date) -> Optional[float]: + best_date: Optional[date] = None + best_value: Optional[float] = None + for item in records: + if not isinstance(item, dict) or normalize_ticker(item) != stock: + continue + event_date = parse_iso_date(item.get("date") or item.get("datetime")) + value = float_or_none(item.get(field)) + if event_date is None or value is None or event_date > report_date: + continue + if best_date is None or event_date >= best_date: + best_date = event_date + best_value = value + return best_value + + +def factor_range(records: List[Any], stock: str, field: str, report_date: date) -> Tuple[Optional[float], Optional[float]]: + values: List[float] = [] + for item in records: + if not isinstance(item, dict) or normalize_ticker(item) != stock: + continue + event_date = parse_iso_date(item.get("date") or item.get("datetime")) + value = float_or_none(item.get(field)) + if event_date is None or value is None or event_date > report_date: + continue + values.append(value) + if not values: + return None, None + return min(values), max(values) + + +def build_as_of_time(report_date: date, *collections: List[Any]) -> str: + latest_dt: Optional[datetime] = None + for collection in collections: + for item in collection: + if not isinstance(item, dict): + continue + for field in ( + "create_tm", + "datetime", + "info_date", + "date", + "report_date", + "advance_date", + "ex_dividend_date", + "end_date", + "published_at", + "retrieved_at", + ): + current = parse_iso_datetime(item.get(field)) + if current and current.date() <= report_date and (latest_dt is None or current > latest_dt): + latest_dt = current + if latest_dt: + if latest_dt.time() == datetime.min.time(): + return latest_dt.strftime("%Y-%m-%d") + return latest_dt.strftime("%Y-%m-%d %H:%M:%S") + return report_date.isoformat() + + +def calculate_price_return(records: List[Any], stock: str) -> Tuple[Optional[float], Optional[float], Optional[float]]: + series: Dict[date, Tuple[float, Optional[float]]] = {} + for item in records: + if not isinstance(item, dict) or normalize_ticker(item) != stock: + continue + event_date = parse_iso_date(item.get("datetime") or item.get("date")) + close = float_or_none(item.get("close")) + if event_date is None or close is None: + continue + series[event_date] = (close, float_or_none(item.get("total_turnover"))) + ordered = sorted(series.items(), key=lambda pair: pair[0]) + if len(ordered) < 2: + return None, None, None + first_close = ordered[0][1][0] + last_close, turnover = ordered[-1][1] + if not first_close: + return None, None, turnover + return (last_close / first_close - 1.0) * 100.0, last_close, turnover + + +def calculate_index_return(records: List[Any]) -> Optional[float]: + series: Dict[date, float] = {} + for item in records: + if not isinstance(item, dict): + continue + event_date = parse_iso_date(item.get("datetime") or item.get("date")) + close = float_or_none(item.get("close")) + if event_date is None or close is None: + continue + series[event_date] = close + ordered = sorted(series.items(), key=lambda pair: pair[0]) + if len(ordered) < 2: + return None + first_close = ordered[0][1] + last_close = ordered[-1][1] + if not first_close: + return None + return (last_close / first_close - 1.0) * 100.0 + + +def latest_dividend(records: List[Any], stock: str) -> Optional[Dict[str, Any]]: + matched = [item for item in records if isinstance(item, dict) and normalize_ticker(item) == stock] + return choose_latest_by_date(matched, ("advance_date", "declaration_announcement_date", "ex_dividend_date", "payable_date")) + + +def shareholder_summary(records: List[Any], stock: str) -> Dict[str, Optional[float]]: + periods: Dict[str, List[Dict[str, Any]]] = {} + for item in records: + if not isinstance(item, dict) or normalize_ticker(item) != stock: + continue + period = str(item.get("end_date") or "") + if period: + periods.setdefault(period, []).append(item) + if not periods: + return { + "latest_period": None, + "top1": None, + "top10": None, + "previous_period": None, + "previous_top10": None, + } + ordered_periods = sorted(periods.keys()) + latest_period = ordered_periods[-1] + previous_period = ordered_periods[-2] if len(ordered_periods) >= 2 else None + + def summarize(period: Optional[str]) -> Tuple[Optional[float], Optional[float]]: + if not period: + return None, None + items = periods[period] + top1 = None + total = 0.0 + count = 0 + for item in items: + pct = float_or_none(item.get("hold_percent_total")) + rank = str(item.get("rank") or "") + if pct is None: + continue + count += 1 + total += pct + if rank == "1": + top1 = pct + return top1, total if count else None + + top1, top10 = summarize(latest_period) + _, previous_top10 = summarize(previous_period) + return { + "latest_period": latest_period, + "top1": top1, + "top10": top10, + "previous_period": previous_period, + "previous_top10": previous_top10, + } + + +LOW_SIGNAL_KEYWORDS = ( + "独立董事述职报告", + "内部控制审计报告", + "内部控制评价报告", + "可持续发展报告", + "履职情况报告", + "履职情况评估报告", + "履行监督职责情况报告", + "投资者保护工作报告", + "已审财务报表", + "专项报告", + "H股公告", + "审计报告", + "独立性自查", + "独立性专项意见", + "管理办法", +) + +CATEGORY_PRIORITY = { + "财报披露": 0, + "资本回报": 1, + "经营催化": 2, + "治理事项": 3, +} + + +def classify_announcement(title: str) -> Optional[str]: + if any(keyword in title for keyword in LOW_SIGNAL_KEYWORDS): + return None + + rules = [ + ("财报披露", ("年度报告", "年报", "半年报", "季报", "业绩报告", "业绩发布会")), + ("资本回报", ("利润分配", "分红", "派息", "回购")), + ("治理事项", ("股东大会", "续聘会计师事务所")), + ("经营催化", ("提质增效", "核心人员持股计划", "长期服务计划", "实施方案", "投资者保护")), + ] + for category, keywords in rules: + if any(keyword in title for keyword in keywords): + return category + return None + + +def catalyst_title_priority(title: str, category: str) -> int: + high_signal_rules = [ + (0, ("年度报告", "年报")), + (1, ("利润分配", "分红", "派息", "回购")), + (2, ("业绩报告", "业绩快报", "业绩预告", "业绩发布会")), + (3, ("半年报", "季报")), + (4, ("核心人员持股计划", "长期服务计划")), + (5, ("董事会", "监事会", "股东大会", "续聘会计师事务所")), + ] + for priority, keywords in high_signal_rules: + if any(keyword in title for keyword in keywords): + return priority + return 10 + CATEGORY_PRIORITY.get(category, 9) + + +def extract_catalysts(records: List[Any], stock: str) -> List[CatalystItem]: + items: List[CatalystItem] = [] + seen = set() + for item in records: + if not isinstance(item, dict) or normalize_ticker(item) != stock: + continue + title = str(item.get("title") or "").strip() + if not title: + continue + category = classify_announcement(title) + if category is None: + continue + event_date = parse_iso_date(item.get("info_date")) + if event_date is None: + continue + key = (title, event_date.isoformat()) + if key in seen: + continue + seen.add(key) + items.append( + CatalystItem( + title=title, + event_date=event_date, + category=category, + priority=catalyst_title_priority(title, category), + announcement_link=normalize_link(item.get("announcement_link")), + media=str(item.get("media") or "未知来源"), + info_type=str(item.get("info_type") or "未分类"), + ) + ) + items.sort(key=lambda item: (-item.event_date.toordinal(), item.priority, item.title)) + return items + + +def format_target_price(target: Any) -> str: + if isinstance(target, dict): + value = target.get("value") + currency = target.get("currency") or "" + if value not in (None, ""): + return f"{value} {currency}".strip() + if target not in (None, ""): + return str(target) + return "未提供" + + +def numeric_target_price(target: Any) -> Optional[float]: + if isinstance(target, dict): + return float_or_none(target.get("value")) + return float_or_none(target) + + +def catalyst_expected_impact(item: CatalystItem) -> str: + if item.category == "财报披露": + return "验证利润、现金流与分红是否支持 thesis 延续" + if item.category == "资本回报": + return "观察股东回报与资金配置是否继续改善" + if item.category == "经营催化": + return "观察经营执行与管理层激励是否强化兑现路径" + if item.category == "治理事项": + return "观察治理安排是否带来新的资本回报或经营催化" + return "观察事件是否触发预期修正" + + +def metric_lookup(financial_records: Sequence[Dict[str, Any]], roe_value: Optional[float], price_return: Optional[float], excess_return: Optional[float], ownership: Dict[str, Optional[float]]) -> Dict[str, Optional[float]]: + latest = financial_records[-1] if financial_records else None + latest_quarter = str(latest.get("quarter")) if latest else "" + yoy = find_yoy_record(financial_records, latest_quarter) if latest_quarter else None + + latest_revenue = float_or_none(latest.get("revenue")) if latest else None + latest_profit = float_or_none(latest.get("net_profit")) if latest else None + latest_cash = float_or_none(latest.get("cash_from_operating_activities")) if latest else None + yoy_revenue = float_or_none(yoy.get("revenue")) if yoy else None + yoy_profit = float_or_none(yoy.get("net_profit")) if yoy else None + + revenue_yoy = ((latest_revenue / yoy_revenue - 1.0) * 100.0) if latest_revenue is not None and yoy_revenue not in (None, 0) else None + profit_yoy = ((latest_profit / yoy_profit - 1.0) * 100.0) if latest_profit is not None and yoy_profit not in (None, 0) else None + cash_profit_ratio = (latest_cash / latest_profit) if latest_cash is not None and latest_profit not in (None, 0) else None + + return { + "latest_revenue_yoy_pct": revenue_yoy, + "latest_net_profit_yoy_pct": profit_yoy, + "latest_cash_profit_ratio": cash_profit_ratio, + "latest_roe": roe_value, + "price_return_6m_pct": price_return, + "excess_return_6m_pct": excess_return, + "top1_holder_pct": ownership.get("top1"), + "top10_holder_pct": ownership.get("top10"), + } + + +def evaluate_rule(value: Optional[float], operator: str, threshold: float) -> bool: + if value is None: + return False + if operator == ">": + return value > threshold + if operator == ">=": + return value >= threshold + if operator == "<": + return value < threshold + if operator == "<=": + return value <= threshold + if operator == "==": + return value == threshold + return False + + +def default_pillars(metrics: Dict[str, Optional[float]]) -> List[Dict[str, Any]]: + return [ + {"name": "收入规模继续扩张", "metric": "latest_revenue_yoy_pct", "operator": ">=", "threshold": 0.0, "rationale": "默认框架要求收入同比不转负。"}, + {"name": "归母净利润保持增长", "metric": "latest_net_profit_yoy_pct", "operator": ">=", "threshold": 0.0, "rationale": "默认框架要求利润同比延续正增长。"}, + {"name": "ROE维持双位数", "metric": "latest_roe", "operator": ">=", "threshold": 10.0, "rationale": "默认框架要求资本效率保持双位数。"}, + {"name": "现金创造不弱于利润", "metric": "latest_cash_profit_ratio", "operator": ">=", "threshold": 1.0, "rationale": "默认框架要求经营现金流覆盖利润。"}, + ] + + +def actual_text_for_metric(metric: str, value: Optional[float]) -> str: + if metric.endswith("_pct") or metric == "latest_roe": + return percent_text(value) + if metric.endswith("_ratio"): + return ratio_text(value) + return percent_text(value) + + +def expectation_text(metric: str, operator: str, threshold: float) -> str: + if metric.endswith("_pct") or metric == "latest_roe": + return f"{operator} {threshold:.1f}%" + if metric.endswith("_ratio"): + return f"{operator} {threshold:.2f}x" + return f"{operator} {threshold}" + + +def default_falsifier_text(metric: str, operator: str, threshold: float) -> str: + threshold_text = expectation_text(metric, "=", threshold).replace("= ", "") + if operator in {">", ">="}: + return f"若该指标回落至 {threshold_text} 以下,则该支柱失效。" + if operator in {"<", "<="}: + return f"若该指标升至 {threshold_text} 以上,则该支柱失效。" + return f"若该指标显著偏离 {threshold_text},则该支柱需要重估。" + + +def build_pillar_results(thesis_definition: Optional[Dict[str, Any]], metrics: Dict[str, Optional[float]]) -> Tuple[str, List[PillarResult]]: + source = "用户提供的投资逻辑定义" if thesis_definition else "默认财务质量框架" + pillar_defs = thesis_definition.get("pillars") if thesis_definition else None + if not isinstance(pillar_defs, list) or not pillar_defs: + pillar_defs = default_pillars(metrics) + + results: List[PillarResult] = [] + for item in pillar_defs: + if not isinstance(item, dict): + continue + metric = str(item.get("metric") or "") + operator = str(item.get("operator") or ">=") + threshold = float_or_none(item.get("threshold")) + name = str(item.get("name") or metric or "未命名支柱") + value = metrics.get(metric) + if threshold is None or metric not in metrics: + result = PillarResult( + name=name, + metric=metric, + actual_value=value, + actual_text=actual_text_for_metric(metric, value), + expectation_text="规则无效", + falsifier_text=str(item.get("falsifier") or "支柱定义缺少有效的反证条件。"), + passed=False, + rationale=str(item.get("rationale") or "支柱定义缺少有效 metric/threshold。"), + ) + else: + result = PillarResult( + name=name, + metric=metric, + actual_value=value, + actual_text=actual_text_for_metric(metric, value), + expectation_text=expectation_text(metric, operator, threshold), + falsifier_text=str(item.get("falsifier") or default_falsifier_text(metric, operator, threshold)), + passed=evaluate_rule(value, operator, threshold), + rationale=str(item.get("rationale") or "无额外说明"), + ) + results.append(result) + return source, results + + +def conviction_label(passed_count: int, total_count: int) -> str: + if total_count <= 0: + return "低" + ratio = passed_count / total_count + if ratio >= 0.75: + return "高" + if ratio >= 0.5: + return "中" + return "低" + + +def finding_type_label(finding_type: str) -> str: + labels = { + "company_news": "公司动态", + "management_change": "管理层变化", + "industry_trend": "行业趋势", + "competition_context": "竞争格局", + "analyst_view": "分析师观点", + } + return labels.get(finding_type, "网络搜索结果") + + +def build_external_context_lines( + findings: Sequence[ExternalFinding], + heading: str, + limit: int = 4, +) -> List[str]: + if not findings: + return [] + lines = [heading] + for item in findings[:limit]: + lines.append( + f"- **{item.published_at.isoformat()} {item.source_name} / {finding_type_label(item.finding_type)}**:" + f"{item.title}。{item.summary}。与投资逻辑的关系:{item.why_relevant}。" + f" *数据来源:{item.source_name},置信度{item.confidence}*" + ) + return lines + + +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"警告:仓库内 report-renderer 渲染失败:{exc}") + + print("警告:未找到可用的 HTML 渲染器,保留 Markdown 输出") + return None + + +def main() -> None: + args = parse_args() + data_dir = Path(args.data_dir).expanduser() + report_date = date.fromisoformat(args.report_date) + + thesis_definition = load_thesis_definition(data_dir / "thesis_definition.json") + instrument_records = extract_records(read_json_file(data_dir / "instrument_meta.json")) + historical_financials = extract_records(read_json_file(data_dir / "historical_financials.json")) + latest_financials = extract_records(read_json_file(data_dir / "latest_financials.json")) + roe_records = extract_records(read_json_file(data_dir / "roe.json")) + price_records = extract_records(read_json_file(data_dir / "price_6m.json")) + hs300_records = extract_records(read_json_file(data_dir / "hs300_6m.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")) + dividend_records = extract_records(read_json_file(data_dir / "dividend.json")) + shareholder_records = extract_records(read_json_file(data_dir / "shareholder_top10.json")) + announcement_records = extract_records(read_json_file(data_dir / "announcement_raw.json")) + web_search_records = extract_records(read_json_file(data_dir / "web_search_findings.json")) + validate_web_search_records(web_search_records) + network_search_findings = extract_external_findings(web_search_records) + + company_name = args.company + if not company_name: + for record in instrument_records: + if isinstance(record, dict) and normalize_ticker(record) == args.stock: + company_name = normalize_name(record) + break + company_name = company_name or args.stock + + financial_history = dedupe_financial_records(historical_financials, args.stock, report_date) + financial_latest = dedupe_financial_records(latest_financials, args.stock, report_date) + base_financials = financial_latest or financial_history + + latest_record = base_financials[-1] if base_financials else None + latest_quarter = str(latest_record.get("quarter")) if latest_record else "无数据" + yoy_record = find_yoy_record(base_financials, latest_quarter) if latest_record else None + + price_return, latest_price, latest_turnover = calculate_price_return(price_records, args.stock) + benchmark_return = calculate_index_return(hs300_records) + excess_return = (price_return - benchmark_return) if price_return is not None and benchmark_return is not None else None + roe_value = latest_roe_value(roe_records, args.stock) + latest_pe = latest_factor_value(pe_records, args.stock, "pe_ratio", report_date) + latest_pb = latest_factor_value(pb_records, args.stock, "pb_ratio", report_date) + pe_min, pe_max = factor_range(pe_records, args.stock, "pe_ratio", report_date) + pb_min, pb_max = factor_range(pb_records, args.stock, "pb_ratio", report_date) + ownership = shareholder_summary(shareholder_records, args.stock) + latest_dividend_record = latest_dividend(dividend_records, args.stock) + catalysts = extract_catalysts(announcement_records, args.stock) + as_of_time = build_as_of_time( + report_date, + latest_financials, + historical_financials, + roe_records, + pe_records, + pb_records, + price_records, + hs300_records, + dividend_records, + shareholder_records, + announcement_records, + web_search_records, + ) + + metrics = metric_lookup(base_financials, roe_value, price_return, excess_return, ownership) + thesis_source, pillars = build_pillar_results(thesis_definition, metrics) + high_conf_network_findings = [item for item in network_search_findings if item.confidence >= 4] + low_conf_network_findings = [item for item in network_search_findings if item.confidence <= 3] + + passed_count = sum(1 for item in pillars if item.passed) + conviction = conviction_label(passed_count, len(pillars)) + target_price_value = numeric_target_price((thesis_definition or {}).get("target_price")) + target_price_text = format_target_price((thesis_definition or {}).get("target_price")) + target_upside = ((target_price_value / latest_price - 1.0) * 100.0) if target_price_value not in (None, 0) and latest_price not in (None, 0) else None + initial_confidence = str((thesis_definition or {}).get("confidence_label") or "未提供") + core_view = str((thesis_definition or {}).get("core_view") or "未提供用户自定义投资逻辑,以下按默认财务质量框架跟踪。") + holding_period = str((thesis_definition or {}).get("holding_period") or "未提供") + position_date = str((thesis_definition or {}).get("position_date") or "未提供") + current_position = str((thesis_definition or {}).get("current_position") or (thesis_definition or {}).get("position") or "未提供") + thesis_name = str((thesis_definition or {}).get("thesis_name") or f"{company_name} 默认跟踪框架") + + latest_revenue = float_or_none(latest_record.get("revenue")) if latest_record else None + latest_profit = float_or_none(latest_record.get("net_profit")) if latest_record else None + latest_cash = float_or_none(latest_record.get("cash_from_operating_activities")) if latest_record else None + + exec_summary_lines = [ + f"当前跟踪对象为 {company_name}({args.stock}),本次沿用 thesis `{thesis_name}`。最新识别报告期为 {latest_quarter}," + f"共验证 {len(pillars)} 项关键支柱,其中通过 {passed_count} 项,当前信念度评估为 `{conviction}`。", + f"当前价为 {price_text(latest_price)},目标价为 {target_price_text},静态空间约 {percent_text(target_upside)};" + f"最近 6 个月股价表现为 {percent_text(price_return)},相对沪深300 的超额收益为 {percent_text(excess_return)}。", + f"最近 ROE 为 {unsigned_percent_text(roe_value)},经营现金流/净利润为 {ratio_text(metrics['latest_cash_profit_ratio'])};" + f"当前 PE / PB 约为 {ratio_text(latest_pe, 1)} / {ratio_text(latest_pb)}。", + ( + f"最近 30 天筛选出 {len(catalysts)} 条高相关度公告催化,资本回报方面最新分红记录为 `{latest_dividend_record.get('quarter')}`," + f"前十大股东合计持股比例约 {unsigned_percent_text(ownership['top10'], 2)}。" + if latest_dividend_record + else f"最近 30 天筛选出 {len(catalysts)} 条高相关度公告催化,当前股东结构期末为 `{ownership['latest_period'] or '无数据'}`。" + ), + ] + if network_search_findings: + exec_summary_lines.append( + f"本次同时纳入 {len(network_search_findings)} 条网络搜索结果,主要用于补充公司动态、管理层变化或行业趋势验证;" + "这些结果只作为补充证据,不直接替代 RQData 主数据。" + ) + exec_summary_lines.extend( + [ + "", + "*数据来源:RQData,置信度5*", + ] + ) + if network_search_findings: + exec_summary_lines.append( + f"*补充网络搜索结果:{network_search_findings[0].source_name}" + f" 等 {len(network_search_findings)} 个来源,置信度" + f"{min(item.confidence for item in network_search_findings)}-{max(item.confidence for item in network_search_findings)}*" + ) + exec_summary = "\n".join(exec_summary_lines) + + snapshot_lines = [ + f"- **论文名称**:{thesis_name}", + f"- **核心观点**:{core_view}", + f"- **最新报告期**:{latest_quarter}", + f"- **建仓/起始日期**:{position_date}", + f"- **当前持仓/关注仓位**:{current_position}", + f"- **持有周期**:{holding_period}", + f"- **当前价 / 目标价 / 空间**:{price_text(latest_price)} / {target_price_text} / {percent_text(target_upside)}", + f"- **目标价**:{target_price_text}", + f"- **初始信念度**:{initial_confidence}", + f"- **当前信念度**:{conviction}", + f"- **当前估值(PE / PB)**:{ratio_text(latest_pe, 1)} / {ratio_text(latest_pb)}", + f"- **逻辑定义来源**:{thesis_source}", + "", + "*数据来源:RQData,置信度5*", + ] + thesis_snapshot = "\n".join(snapshot_lines) + + pillar_lines = [ + "| 支柱 | 实际值 | 验证规则 | 反证条件 | 状态 | 说明 |", + "| --- | ---: | ---: | --- | --- | --- |", + ] + for item in pillars: + status = "通过" if item.passed else "未通过" + pillar_lines.append( + f"| {item.name} | {item.actual_text} | {item.expectation_text} | {item.falsifier_text} | {status} | {item.rationale} |" + ) + if latest_record and yoy_record: + pillar_lines.append("") + pillar_lines.append( + f"最新报告期 {latest_quarter} 对应营收 {yuan_text(latest_revenue)}、净利润 {yuan_text(latest_profit)}、经营现金流 {yuan_text(latest_cash)};" + f"对比去年同期后,营收同比 {percent_text(metrics['latest_revenue_yoy_pct'])},净利润同比 {percent_text(metrics['latest_net_profit_yoy_pct'])}。" + ) + pillar_lines.append("") + pillar_lines.append("### 支柱详解") + for idx, item in enumerate(pillars, start=1): + pillar_lines.extend( + [ + f"- **支柱{idx}:{item.name}**", + f" 原始假设/验证逻辑:{item.rationale}", + f" 验证规则:{item.expectation_text}", + f" 反证条件:{item.falsifier_text}", + f" 当前读数:{item.actual_text}", + f" 结论:{'当前继续成立' if item.passed else '当前未完全成立,需要继续跟踪'}", + ] + ) + pillar_lines.append("") + pillar_lines.append("*数据来源:RQData,置信度5*") + pillar_verification = "\n".join(pillar_lines) + + market_lines = [ + f"- **最新股价**:{price_text(latest_price)}", + f"- **目标价 / 静态空间**:{target_price_text} / {percent_text(target_upside)}", + f"- **6个月股价回报**:{percent_text(price_return)}", + f"- **沪深300回报**:{percent_text(benchmark_return)}", + f"- **超额收益**:{percent_text(excess_return)}", + f"- **最近成交额**:{yuan_text(latest_turnover)}" if latest_turnover is not None else "- **最近成交额**:无数据", + f"- **当前 PE / 历史区间**:{ratio_text(latest_pe, 1)} / {ratio_text(pe_min, 1)} - {ratio_text(pe_max, 1)}", + f"- **当前 PB / 历史区间**:{ratio_text(latest_pb)} / {ratio_text(pb_min)} - {ratio_text(pb_max)}", + "", + "| 指标 | 当前值 | 说明 |", + "| --- | ---: | --- |", + f"| 当前价 | {price_text(latest_price)} | 市场最新交易价格 |", + f"| 目标价 | {target_price_text} | thesis 预设目标价 |", + f"| 目标空间 | {percent_text(target_upside)} | 目标价相对当前价的静态空间 |", + f"| PE | {ratio_text(latest_pe, 1)} | 当前盈利估值水平 |", + f"| PB | {ratio_text(latest_pb)} | 当前资产/资本回报估值水平 |", + "", + ] + if excess_return is not None: + if excess_return >= 0: + market_lines.append("股价相对基准保持超额收益,说明 thesis 至少没有被市场完全否定,当前更需要验证估值修复是否仍有基本面支撑。") + else: + market_lines.append("股价相对基准为负超额收益,说明 thesis 需要更严格地接受数据、催化剂和估值三方面的再验证。") + market_lines.append("") + market_lines.append("*数据来源:RQData,置信度5*") + market_performance = "\n".join(market_lines) + + capital_lines = [] + if latest_dividend_record: + round_lot = latest_dividend_record.get("round_lot") or "10" + capital_lines.extend( + [ + f"- **最新分红记录**:{latest_dividend_record.get('quarter')},每 {round_lot} 股税前现金分红 {latest_dividend_record.get('dividend_cash_before_tax')}。", + f"- **预案公告日**:{latest_dividend_record.get('advance_date') or '无数据'}", + f"- **除权除息日**:{latest_dividend_record.get('ex_dividend_date') or '无数据'}", + ] + ) + else: + capital_lines.append("- **最新分红记录**:无数据") + + capital_lines.extend( + [ + f"- **最新股东结构期末**:{ownership['latest_period'] or '无数据'}", + f"- **第一大股东持股比例**:{unsigned_percent_text(ownership['top1'], digits=2)}", + f"- **前十大股东合计持股比例**:{unsigned_percent_text(ownership['top10'], digits=2)}", + ] + ) + if ownership["top10"] is not None and ownership["previous_top10"] is not None: + change = ownership["top10"] - ownership["previous_top10"] + capital_lines.append( + f"- **与上一期前十大股东集中度变化**:{percent_text(change, digits=2)}(上一期为 {ownership['previous_period']})。" + ) + capital_lines.extend( + [ + "", + "| 观察项 | 当前值 | 说明 |", + "| --- | ---: | --- |", + f"| 第一大股东持股比例 | {unsigned_percent_text(ownership['top1'], 2)} | 观察控制权稳定性 |", + f"| 前十大股东合计持股 | {unsigned_percent_text(ownership['top10'], 2)} | 观察筹码集中度 |", + f"| 最新分红季度 | {latest_dividend_record.get('quarter') if latest_dividend_record else '无数据'} | 观察资本回报节奏 |", + ] + ) + capital_lines.append("") + capital_lines.append("*数据来源:RQData,置信度5*") + capital_return = "\n".join(capital_lines) + + catalyst_lines = ["### 催化剂日历"] + if catalysts: + catalyst_lines.extend( + [ + "| 日期 | 催化剂 | 预期影响 | 实际结果 | 状态 |", + "| --- | --- | --- | --- | --- |", + ] + ) + for item in catalysts[:8]: + catalyst_lines.append( + f"| {item.event_date.isoformat()} | `{item.title}` | {catalyst_expected_impact(item)} | {item.category} / {item.info_type} | 已披露 |" + ) + else: + catalyst_lines.append("- 最近 30 天未识别到高相关度公告催化。") + + catalyst_lines.append("") + catalyst_lines.append("### 已实现/已披露催化剂详解") + if catalysts: + for item in catalysts[:6]: + link_text = format_link_markdown(item.announcement_link) + catalyst_lines.append( + f"- **{item.event_date.isoformat()} `{item.title}`**:归类为{item.category},来源 {item.media};" + f"{catalyst_expected_impact(item)}" + (f" {link_text}" if link_text else "") + ) + else: + catalyst_lines.append("- 最近 30 天未识别到可跟踪的高优先级公告催化。") + + catalyst_lines.append("") + if high_conf_network_findings: + catalyst_lines.extend(build_external_context_lines(high_conf_network_findings, "### 网络搜索结果")) + catalyst_lines.append("") + elif network_search_findings: + catalyst_lines.extend(build_external_context_lines(network_search_findings, "### 网络搜索结果")) + catalyst_lines.append("") + if low_conf_network_findings: + catalyst_lines.extend(build_external_context_lines(low_conf_network_findings, "### 需二次验证的网络搜索结果", limit=3)) + catalyst_lines.append("") + + catalyst_lines.append("### 待验证/待兑现催化剂") + planned = (thesis_definition or {}).get("planned_catalysts") + if isinstance(planned, list) and planned: + catalyst_lines.extend( + [ + "| 预计窗口 | 催化剂 | 预期影响 | 状态 |", + "| --- | --- | --- | --- |", + ] + ) + for item in planned: + if not isinstance(item, dict): + continue + catalyst_lines.append( + f"| {item.get('expected_window', '未提供')} | {item.get('title', '未命名催化剂')} | {item.get('expected_impact', '等待后续公告/财报验证')} | 待兑现 |" + ) + elif latest_dividend_record: + catalyst_lines.append("- **资本回报兑现**:跟踪利润分配方案后续执行节奏以及除权除息安排。") + catalyst_lines.append("- **下一次定期披露**:等待下一季报/中报验证利润与现金流延续性。") + else: + catalyst_lines.append("- **下一次定期披露**:等待下一次财报窗口验证关键支柱是否延续。") + catalyst_lines.append("") + catalyst_lines.append("*数据来源:RQData,置信度5*") + catalyst_tracking = "\n".join(catalyst_lines) + + risk_rows: List[Tuple[str, str, str, str, str]] = [] + risk_items = (thesis_definition or {}).get("risk_items") + if isinstance(risk_items, list) and risk_items: + for item in risk_items: + if not isinstance(item, dict): + continue + risk_rows.append( + ( + str(item.get("title") or "未命名风险"), + str(item.get("initial_assessment") or "中"), + str(item.get("monitor") or "未提供监控要点"), + "↑" if excess_return is not None and excess_return < 0 else "→", + str(item.get("response") or "结合下一次财报、公告与价格表现继续验证"), + ) + ) + else: + if excess_return is not None and excess_return < 0: + risk_rows.append(("市场验证风险", "中", "股价最近 6 个月跑输沪深300,需要更高频验证 thesis。", "↑", "缩短验证节奏,优先跟踪价格与催化剂背离。")) + failed_pillars = [item for item in pillars if not item.passed] + if failed_pillars: + names = "、".join(item.name for item in failed_pillars) + risk_rows.append(("支柱失效风险", "中高", f"以下支柱尚未通过验证:{names}。", "↑", "等待下一次财报与公告继续验证。")) + if low_conf_network_findings: + risk_rows.append(("网络搜索结果确认风险", "中", "部分网络搜索结果来自低置信度来源,只能作为跟踪线索。", "→", "优先等待公告、财报或更高等级来源交叉验证。")) + if not risk_rows: + risk_rows.append(("暂无突出新增风险", "低", "当前未识别到新的显著风险信号。", "→", "继续跟踪下一次定期披露和资本回报执行。")) + + risk_lines = [ + "### 风险矩阵", + "| 风险 | 原始评估 | 当前状态 | 趋势 | 应对措施 |", + "| --- | --- | --- | --- | --- |", + ] + for title, initial_state, current_state, trend, response in risk_rows: + risk_lines.append(f"| {title} | {initial_state} | {current_state} | {trend} | {response} |") + risk_lines.append("") + risk_lines.append("### 风险详解") + for title, initial_state, current_state, trend, response in risk_rows: + risk_lines.append(f"- **{title}**:原始评估 `{initial_state}`,当前状态为 {current_state};趋势 `{trend}`,当前应对为 {response}。") + risk_lines.append("") + risk_lines.append("*数据来源:RQData,置信度5*") + risk_monitoring = "\n".join(risk_lines) + + update_lines = [ + f"- **{report_date.isoformat()} 更新**:最新跟踪报告期为 `{latest_quarter}`,共验证 {len(pillars)} 项支柱,通过 {passed_count} 项,当前信念度更新为 `{conviction}`。", + f"- **价格与估值**:当前价 {price_text(latest_price)},目标价 {target_price_text},静态空间 {percent_text(target_upside)};当前 PE / PB 为 {ratio_text(latest_pe, 1)} / {ratio_text(latest_pb)}。", + f"- **催化剂增量**:最近 30 天识别出 {len(catalysts)} 条高相关度公告催化,最高优先级事项为 `{catalysts[0].title}`。" if catalysts else "- **催化剂增量**:最近 30 天未识别到新增高优先级催化。", + ( + f"- **资本回报与股东结构**:最新分红记录为 `{latest_dividend_record.get('quarter')}`,前十大股东合计持股比例约 {unsigned_percent_text(ownership['top10'], 2)}。" + if latest_dividend_record + else f"- **股东结构**:最新股东结构期末为 `{ownership['latest_period'] or '无数据'}`,前十大股东合计持股比例约 {unsigned_percent_text(ownership['top10'], 2)}。" + ), + ] + if network_search_findings: + update_lines.append( + f"- **网络搜索结果增量**:本次补充 {len(network_search_findings)} 条网络搜索结果,最新一条为 `{network_search_findings[0].title}`。" + ) + update_lines.extend( + [ + "", + "*数据来源:RQData,置信度5*", + ] + ) + update_log = "\n".join(update_lines) + + appendix_lines = [ + f"- 报告日期为 {report_date.isoformat()},信息截面为 {as_of_time}。", + f"- 投资逻辑定义来源:{thesis_source}。", + "- 财务数据按季度去重,保留同一季度最新披露版本。", + "- 公告部分会过滤低信号治理/例行披露,优先保留财报、资本回报、经营催化与治理关键节点,并保留高优先级事项的原文链接。", + ( + f"- 本次共纳入 {len(network_search_findings)} 条网络搜索结果,用于补充公司动态、管理层变化和行业趋势验证。" + if network_search_findings + else "- 本次未纳入网络搜索结果,报告保持 RQData 驱动的基础 thesis 跟踪口径。" + ), + "", + "### 数据覆盖情况", + f"- 逻辑定义:{'已提供用户自定义逻辑' if thesis_definition else '未提供用户自定义逻辑'}", + f"- 公司基础信息:{len(instrument_records)} 条记录", + f"- 最新财务样本:{len(latest_financials)} 条记录", + f"- 历史财务样本:{len(historical_financials)} 条记录", + f"- ROE 样本:{len(roe_records)} 条记录", + f"- 股价样本:{len(price_records)} 条记录", + f"- 基准指数样本:{len(hs300_records)} 条记录", + f"- PE 样本:{len(pe_records)} 条记录", + f"- PB 样本:{len(pb_records)} 条记录", + f"- 分红样本:{len(dividend_records)} 条记录", + f"- 股东结构样本:{len(shareholder_records)} 条记录", + f"- 公告样本:{len(announcement_records)} 条记录", + f"- 网络搜索结果样本:{len(web_search_records)} 条记录", + ] + appendix = "\n".join(appendix_lines) + + template_path = Path(args.template).expanduser() + report_text = render_template( + template_path.read_text(encoding="utf-8"), + { + "REPORT_DATE": report_date.isoformat(), + "AS_OF_TIME": as_of_time, + "COMPANY_NAME": company_name, + "STOCK_CODE": args.stock, + "THESIS_SOURCE": thesis_source, + "EXEC_SUMMARY": exec_summary, + "THESIS_SNAPSHOT": thesis_snapshot, + "PILLAR_VERIFICATION": pillar_verification, + "MARKET_PERFORMANCE": market_performance, + "CAPITAL_RETURN": capital_return, + "CATALYST_TRACKING": catalyst_tracking, + "RISK_MONITORING": risk_monitoring, + "UPDATE_LOG": update_log, + "APPENDIX": appendix, + }, + ) + + if args.output: + output_path = Path(args.output).expanduser() + else: + output_path = data_dir / f"thesis_tracker_{args.stock}_{report_date.isoformat()}.md" + 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() diff --git a/.gitignore b/.gitignore index 64da343cd..e4f6cb6e9 100644 --- a/.gitignore +++ b/.gitignore @@ -91,6 +91,14 @@ mcp_server/src/quantdinger_mcp.egg-info/ backend_api_python/data/quantdinger.db backend_api_python/data/memory/ +# ======================== +# Local market-data archive (DATE/) +# ======================== +/DATE/** +!/DATE/.gitkeep + +.cursor/skills/**/cache/ + # ======================== # Docker # ======================== diff --git a/DATE/.gitkeep b/DATE/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/backend_api_python/app/data_sources/cn_futures_symbols.py b/backend_api_python/app/data_sources/cn_futures_symbols.py new file mode 100644 index 000000000..32b16f01d --- /dev/null +++ b/backend_api_python/app/data_sources/cn_futures_symbols.py @@ -0,0 +1,98 @@ +"""Chinese domestic futures symbol helpers shared by AkShare and RQData adapters.""" +from __future__ import annotations + +import re +from typing import Optional + +# SC2611, RB0, IF2609, IF88, IF888, IF2609.CFE +_CN_FUTURES_PATTERN = re.compile( + r"^(?P[A-Z]{1,3})(?P\d{1,4}|88A2)(?:\.(?P[A-Z]{2,4}))?$" +) + +_CN_FINANCIAL_PRODUCTS = frozenset({"IF", "IH", "IC", "IM", "T", "TF", "TS", "TL"}) + +_CONTINUOUS_CONTRACTS = frozenset({"0", "00", "88", "888", "889", "99", "88A2"}) + +# Ricequant exchange suffixes. Unknown products are tried without a suffix. +_PRODUCT_EXCHANGE = { + "IF": "CFE", "IH": "CFE", "IC": "CFE", "IM": "CFE", + "T": "CFE", "TF": "CFE", "TS": "CFE", "TL": "CFE", + "CU": "SHFE", "AL": "SHFE", "ZN": "SHFE", "PB": "SHFE", + "NI": "SHFE", "SN": "SHFE", "AU": "SHFE", "AG": "SHFE", + "RB": "SHFE", "HC": "SHFE", "SS": "SHFE", "BU": "SHFE", + "RU": "SHFE", "FU": "SHFE", "SP": "SHFE", "WR": "SHFE", + "AO": "SHFE", "BR": "SHFE", "AD": "SHFE", + "SC": "INE", "NR": "INE", "LU": "INE", "BC": "INE", "EC": "INE", + "SI": "GFEX", "LC": "GFEX", "PS": "GFEX", + "C": "DCE", "CS": "DCE", "A": "DCE", "B": "DCE", "M": "DCE", + "Y": "DCE", "P": "DCE", "FB": "DCE", "BB": "DCE", "JD": "DCE", + "L": "DCE", "V": "DCE", "PP": "DCE", "EG": "DCE", "EB": "DCE", + "PG": "DCE", "LH": "DCE", "LG": "DCE", + "CF": "CZCE", "SR": "CZCE", "TA": "CZCE", "MA": "CZCE", "OI": "CZCE", + "RI": "CZCE", "WH": "CZCE", "PM": "CZCE", "FG": "CZCE", "SF": "CZCE", + "SM": "CZCE", "UR": "CZCE", "SA": "CZCE", "PF": "CZCE", "PK": "CZCE", + "CY": "CZCE", "AP": "CZCE", "CJ": "CZCE", "RM": "CZCE", "RS": "CZCE", + "JR": "CZCE", "LR": "CZCE", "ZC": "CZCE", "PX": "CZCE", "SH": "CZCE", +} + + +def parse_cn_futures_symbol(symbol: str) -> Optional[re.Match[str]]: + return _CN_FUTURES_PATTERN.match((symbol or "").strip().upper()) + + +def is_cn_futures_symbol(symbol: str) -> bool: + """Return True when the symbol looks like a Chinese domestic futures contract.""" + return parse_cn_futures_symbol(symbol) is not None + + +def cn_futures_product(symbol: str) -> str: + m = parse_cn_futures_symbol(symbol) + return m.group("product") if m else "" + + +def cn_futures_sina_market(symbol: str) -> str: + return "FF" if cn_futures_product(symbol) in _CN_FINANCIAL_PRODUCTS else "CF" + + +def _normalize_czce_contract(product: str, contract: str) -> str: + """CZCE codes in RQData are year-padded (CF701 -> CF1701).""" + if _PRODUCT_EXCHANGE.get(product) != "CZCE": + return contract + if contract in _CONTINUOUS_CONTRACTS or len(contract) >= 4: + return contract + if len(contract) == 3: + # 609 -> 2609 for 2020s contracts + return "2" + contract + return contract + + +def suggest_cn_continuous_symbols(keyword: str) -> list[str]: + """Map a search keyword onto Sina-style continuous codes (RB -> RB0).""" + kw = (keyword or "").strip().upper() + if not kw: + return [] + if len(kw) == 1: + return [f"{kw}0"] if kw in _PRODUCT_EXCHANGE else [] + out: list[str] = [] + seen = set() + for product in sorted(_PRODUCT_EXCHANGE, key=lambda item: (-len(item), item)): + continuous = f"{product}0" + if kw in {product, continuous} or continuous.startswith(kw) or kw.startswith(product): + if continuous not in seen: + seen.add(continuous) + out.append(continuous) + return out + + +def to_rqdata_order_book_id(symbol: str) -> str: + """Map QuantDinger/Sina-style codes to RQData order_book_id.""" + m = parse_cn_futures_symbol(symbol) + if not m: + return (symbol or "").strip().upper() + product = m.group("product") + contract = m.group("contract") + if contract in {"0", "00"}: + contract = "88" + contract = _normalize_czce_contract(product, contract) + # RQData futures IDs are bare (IF2609, SC2609, RB88), not IF2609.CFE. + return f"{product}{contract}" diff --git a/backend_api_python/app/data_sources/cn_stock.py b/backend_api_python/app/data_sources/cn_stock.py index 49cb5155e..9e76fbfdc 100644 --- a/backend_api_python/app/data_sources/cn_stock.py +++ b/backend_api_python/app/data_sources/cn_stock.py @@ -1,12 +1,7 @@ """ -中国A股数据源 — 多层 fallback +中国A股数据源 — Ricequant RQData 优先,其它源回退 -有 TWELVE_DATA_API_KEY: - 所有周期 → Twelve Data(主) → 腾讯日/周线 → yfinance → AkShare - -无 API Key: - 分钟/小时 → yfinance → AkShare - 日/周线 → 腾讯 fqkline → yfinance → AkShare +RQData → Twelve Data → 腾讯日/周线 → yfinance → AkShare """ from __future__ import annotations @@ -28,11 +23,18 @@ class CNStockDataSource(BaseDataSource): - """A股数据源(TwelveData + Tencent + yfinance + AkShare)""" + """A股数据源(RQData + TwelveData + Tencent + yfinance + AkShare)""" - name = "CNStock/multi-source" + name = "CNStock/rqdata" def get_ticker(self, symbol: str) -> Dict[str, Any]: + from app.data_sources.rqdata_equity import cn_stock_source_mode, get_cn_stock_ticker_rqdata + + mode = cn_stock_source_mode() + if mode != "legacy": + quote = get_cn_stock_ticker_rqdata(symbol) + if quote.get("last"): + return quote code = normalize_cn_code(symbol) parts = fetch_quote(code) if not parts: @@ -62,7 +64,21 @@ def get_kline( tf = normalize_chart_timeframe(timeframe) lim = max(int(limit or 300), 1) - # Tier 1: Twelve Data (paid, most reliable) + from app.data_sources.rqdata_equity import cn_stock_source_mode, get_cn_stock_kline_rqdata + + mode = cn_stock_source_mode() + if mode != "legacy": + rows = get_cn_stock_kline_rqdata(symbol, tf, lim, before_time) + if rows: + return self.filter_and_limit( + rows, + limit=lim, + before_time=before_time, + after_time=after_time, + truncate=(after_time is None), + ) + + # Tier 2: Twelve Data rows = fetch_twelvedata_klines( is_hk=False, tencent_code=code, timeframe=tf, limit=lim, before_time=before_time ) diff --git a/backend_api_python/app/data_sources/factory.py b/backend_api_python/app/data_sources/factory.py index da2f84388..6f8853eec 100644 --- a/backend_api_python/app/data_sources/factory.py +++ b/backend_api_python/app/data_sources/factory.py @@ -269,11 +269,23 @@ def get_kline_with_diagnostics( """Fetch K-lines and retain a structured provider failure when rows are empty.""" m = cls.normalize_market(market or "") try: + local_rows = cls._read_date_archive( + market=m, + symbol=symbol, + timeframe=timeframe, + limit=limit, + before_time=before_time, + after_time=after_time, + ) + if local_rows: + return local_rows, None + assert_fd_available(f"market-data kline {m}:{symbol}") source = cls._resolve_source(m, exchange_id=exchange_id, market_type=market_type) klines = source.get_kline(symbol, timeframe, limit, before_time, after_time) klines.sort(key=lambda x: x['time']) + cls._write_date_archive(m, symbol, timeframe, klines) failure = None if not klines: get_last_failure = getattr(source, "get_last_failure", None) @@ -323,7 +335,62 @@ def get_kline_with_diagnostics( symbol=symbol, timeframe=timeframe, ) - + + @classmethod + def _read_date_archive( + cls, + *, + market: str, + symbol: str, + timeframe: str, + limit: int, + before_time: Optional[int], + after_time: Optional[int], + ) -> Optional[List[Dict[str, Any]]]: + try: + from app.data_sources import local_archive as archive + + if not archive.should_read(market, symbol): + return None + stored = archive.read_klines(market, symbol, timeframe) + if not archive.local_covers( + stored, + limit=limit, + before_time=before_time, + after_time=after_time, + timeframe=timeframe, + ): + return None + rows = archive.filter_klines( + stored, + limit=limit, + before_time=before_time, + after_time=after_time, + truncate=(after_time is None), + ) + return rows or None + except Exception as exc: + logger.debug("DATE archive read skipped for %s:%s: %s", market, symbol, exc) + return None + + @classmethod + def _write_date_archive( + cls, + market: str, + symbol: str, + timeframe: str, + klines: List[Dict[str, Any]], + ) -> None: + if not klines: + return + try: + from app.data_sources import local_archive as archive + + if archive.should_write(market, symbol): + archive.merge_write(market, symbol, timeframe, klines) + except Exception as exc: + logger.debug("DATE archive write skipped for %s:%s: %s", market, symbol, exc) + @classmethod def _resolve_source( cls, diff --git a/backend_api_python/app/data_sources/futures.py b/backend_api_python/app/data_sources/futures.py index 9565a78c9..7e5cfbab2 100644 --- a/backend_api_python/app/data_sources/futures.py +++ b/backend_api_python/app/data_sources/futures.py @@ -1,18 +1,19 @@ """ 期货数据源 支持: -1. 加密货币期货(Binance Futures via CCXT) -2. 传统期货(三级降级: Twelve Data → yfinance → Tiingo(贵金属)) +1. 国内期货(RQData 优先,AkShare/新浪回退;副本见 futures_akshare.py) +2. 传统外盘期货(Twelve Data → yfinance → Tiingo(贵金属)) +3. 加密货币期货(Binance Futures via CCXT) """ from typing import Dict, List, Any, Optional from datetime import datetime, timedelta import os -import time import ccxt import requests import yfinance as yf from app.data_sources.base import BaseDataSource, TIMEFRAME_SECONDS +from app.data_sources.cn_futures_symbols import is_cn_futures_symbol from app.utils.logger import get_logger from app.config import CCXTConfig, TiingoConfig, APIKeys @@ -110,10 +111,12 @@ def __init__(self): def get_ticker(self, symbol: str) -> Dict[str, Any]: """ Get latest ticker for futures symbol. - Traditional futures: Twelve Data → yfinance fallback. + Chinese futures: RQData → AkShare. Traditional: Twelve Data → yfinance. Crypto futures: CCXT. """ sym = (symbol or "").strip() + if is_cn_futures_symbol(sym): + return self._get_cn_futures_ticker(sym) is_traditional = sym in self.YF_SYMBOLS or sym.endswith("=F") or sym in _TD_FUTURES_SYMBOLS if is_traditional: for fetcher in (self._get_ticker_twelvedata, self._get_ticker_yfinance, self._get_ticker_tiingo): @@ -135,6 +138,23 @@ def get_ticker(self, symbol: str) -> Dict[str, Any]: sym = f"{sym[:-3]}/USD" return self.exchange.fetch_ticker(sym) + def _get_cn_futures_ticker(self, symbol: str) -> Dict[str, Any]: + """Chinese futures quote: RQData first, AkShare/Sina fallback.""" + from app.data_sources.futures_akshare import get_cn_futures_ticker_akshare + from app.data_sources.rqdata_futures import ( + cn_futures_source_mode, + get_cn_futures_ticker_rqdata, + ) + + mode = cn_futures_source_mode() + if mode != "akshare": + quote = get_cn_futures_ticker_rqdata(symbol) + if quote and float(quote.get("last") or 0) > 0: + return quote + if mode == "rqdata-only": + return quote or {"symbol": symbol, "last": 0.0} + return get_cn_futures_ticker_akshare(symbol) + def _get_ticker_twelvedata(self, symbol: str) -> Optional[Dict[str, Any]]: """Fetch traditional futures quote from Twelve Data.""" api_key = _get_td_api_key() @@ -171,8 +191,6 @@ def _get_ticker_yfinance(self, symbol: str) -> Dict[str, Any]: yf_symbol = yf_symbol + "=F" t = yf.Ticker(yf_symbol) last = None - source = "yfinance" - timestamp = None try: last = getattr(t, "fast_info", {}).get("last_price") except Exception: @@ -181,9 +199,7 @@ def _get_ticker_yfinance(self, symbol: str) -> Dict[str, Any]: hist = t.history(period="2d", interval="1d") if hist is not None and not hist.empty: last = float(hist["Close"].iloc[-1]) - source = "kline_1d" - timestamp = int(hist.index[-1].timestamp()) - return {"symbol": yf_symbol, "last": float(last or 0.0), "source": source, "timestamp": timestamp} + return {"symbol": yf_symbol, "last": float(last or 0.0)} except Exception: return {"symbol": symbol, "last": 0.0} @@ -239,12 +255,34 @@ def get_kline( after_time: 预留与基类一致(当前期货链路未使用) """ _ = after_time + if is_cn_futures_symbol(symbol): + return self._get_cn_futures_kline(symbol, timeframe, limit, before_time) base_symbol = symbol.replace("=F", "").upper() if base_symbol in _TD_FUTURES_SYMBOLS or symbol.endswith('=F'): return self._get_traditional_futures(symbol, timeframe, limit, before_time) else: return self._get_crypto_futures(symbol, timeframe, limit, before_time) + def _get_cn_futures_kline( + self, symbol: str, timeframe: str, limit: int, before_time: Optional[int] = None + ) -> List[Dict[str, Any]]: + """Chinese futures K-lines: RQData first, AkShare/Sina fallback.""" + from app.data_sources.futures_akshare import get_cn_futures_kline_akshare + from app.data_sources.rqdata_futures import ( + cn_futures_source_mode, + get_cn_futures_kline_rqdata, + ) + + mode = cn_futures_source_mode() + if mode != "akshare": + bars = get_cn_futures_kline_rqdata(symbol, timeframe, limit, before_time) + if bars: + return bars + if mode == "rqdata-only": + return [] + logger.info("RQData returned no bars for %s %s; falling back to AkShare", symbol, timeframe) + return get_cn_futures_kline_akshare(symbol, timeframe, limit, before_time) + def _get_traditional_futures( self, symbol: str, diff --git a/backend_api_python/app/data_sources/futures_akshare.py b/backend_api_python/app/data_sources/futures_akshare.py new file mode 100644 index 000000000..9d1d56d64 --- /dev/null +++ b/backend_api_python/app/data_sources/futures_akshare.py @@ -0,0 +1,122 @@ +"""Legacy Chinese futures adapter (AkShare / Sina). + +This is the pre-RQData implementation kept as a fallback copy. US and crypto +futures stay in ``futures.py``. +""" +from __future__ import annotations + +from datetime import datetime +from typing import Any, Dict, List, Optional + +from app.data_sources.base import TIMEFRAME_SECONDS +from app.data_sources.cn_futures_symbols import cn_futures_sina_market +from app.utils.logger import get_logger + +logger = get_logger(__name__) + +_CN_MINUTE_PERIOD_MAP = {"1m": "1", "5m": "5", "15m": "15", "30m": "30", "1H": "60"} + + +def cn_to_timestamp(value) -> Optional[int]: + """Parse a date/datetime-ish value into an epoch second (robust across dtypes).""" + if value is None: + return None + if isinstance(value, (int, float)): + return int(value) + if isinstance(value, datetime): + return int(value.timestamp()) + if hasattr(value, "timestamp"): + try: + return int(value.timestamp()) + except Exception: + pass + if hasattr(value, "timetuple") and not isinstance(value, str): + try: + return int(datetime.combine(value, datetime.min.time()).timestamp()) + except Exception: + pass + s = str(value).strip() + if not s: + return None + for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%d", "%Y%m%d"): + try: + return int(datetime.strptime(s, fmt).timestamp()) + except ValueError: + continue + return None + + +def get_cn_futures_ticker_akshare(symbol: str) -> Dict[str, Any]: + """国内期货实时行情 via AkShare (新浪).""" + sym = (symbol or "").strip().upper() + try: + import akshare as ak # type: ignore + + df = ak.futures_zh_spot(symbol=sym, market=cn_futures_sina_market(sym)) + if df is None or df.empty: + return {"symbol": sym, "last": 0.0} + row = df.iloc[0].to_dict() + last = float(row.get("current_price") or 0) + prev = float(row.get("last_settle_price") or row.get("last_close") or row.get("open") or 0) + change = last - prev if prev else 0.0 + change_pct = (change / prev * 100) if prev else 0.0 + return { + "symbol": sym, + "last": round(last, 4), + "change": round(change, 4), + "changePercent": round(change_pct, 2), + "previousClose": round(prev, 4), + } + except Exception as e: + logger.debug("CN futures ticker (AkShare) failed %s: %s", sym, e) + return {"symbol": sym, "last": 0.0} + + +def get_cn_futures_kline_akshare( + symbol: str, + timeframe: str, + limit: int, + before_time: Optional[int] = None, +) -> List[Dict[str, Any]]: + """国内期货K线 via AkShare (新浪日线 / 分钟线).""" + sym = (symbol or "").strip().upper() + try: + import akshare as ak # type: ignore + + from app.data_sources.rqdata_futures import normalize_cn_futures_timeframe, _resample_ohlcv + + timeframe = normalize_cn_futures_timeframe(timeframe) + want_4h = timeframe == "4H" + minute_period = _CN_MINUTE_PERIOD_MAP.get("1H" if want_4h else timeframe) + if minute_period: + df = ak.futures_zh_minute_sina(symbol=sym, period=minute_period) + date_col = "datetime" + else: + df = ak.futures_zh_daily_sina(symbol=sym) + date_col = "date" + if df is None or df.empty: + return [] + klines = [] + for _, row in df.iterrows(): + ts = cn_to_timestamp(row.get(date_col)) + if ts is None: + continue + klines.append({ + "time": ts, + "open": float(row.get("open") or 0), + "high": float(row.get("high") or 0), + "low": float(row.get("low") or 0), + "close": float(row.get("close") or 0), + "volume": float(row.get("volume") or 0), + }) + klines.sort(key=lambda x: x["time"]) + if want_4h: + klines = _resample_ohlcv(klines, TIMEFRAME_SECONDS["4H"]) + if before_time: + klines = [k for k in klines if k["time"] <= int(before_time)] + if limit and len(klines) > limit: + klines = klines[-limit:] + return klines + except Exception as e: + logger.error("CN futures kline (AkShare) failed %s: %s", sym, e) + return [] diff --git a/backend_api_python/app/data_sources/hk_stock.py b/backend_api_python/app/data_sources/hk_stock.py index 0f48c7f1d..31e45d18e 100644 --- a/backend_api_python/app/data_sources/hk_stock.py +++ b/backend_api_python/app/data_sources/hk_stock.py @@ -1,12 +1,7 @@ """ -港股/H股数据源 — 多层 fallback +港股/H股数据源 — Ricequant RQData 优先,其它源回退 -有 TWELVE_DATA_API_KEY: - 所有周期 → Twelve Data(主) → 腾讯日/周线 → yfinance → AkShare - -无 API Key: - 分钟/小时 → yfinance → AkShare - 日/周线 → 腾讯 fqkline → yfinance → AkShare +RQData → Twelve Data → 腾讯日/周线 → yfinance → AkShare """ from __future__ import annotations @@ -28,11 +23,18 @@ class HKStockDataSource(BaseDataSource): - """港股/H股数据源(TwelveData + Tencent + yfinance + AkShare)""" + """港股/H股数据源(RQData + TwelveData + Tencent + yfinance + AkShare)""" - name = "HKStock/multi-source" + name = "HKStock/rqdata" def get_ticker(self, symbol: str) -> Dict[str, Any]: + from app.data_sources.rqdata_equity import get_hk_stock_ticker_rqdata, hk_stock_source_mode + + mode = hk_stock_source_mode() + if mode != "legacy": + quote = get_hk_stock_ticker_rqdata(symbol) + if quote.get("last"): + return quote code = normalize_hk_code(symbol) parts = fetch_quote(code) if not parts: @@ -62,7 +64,21 @@ def get_kline( tf = normalize_chart_timeframe(timeframe) lim = max(int(limit or 300), 1) - # Tier 1: Twelve Data (paid, most reliable) + from app.data_sources.rqdata_equity import get_hk_stock_kline_rqdata, hk_stock_source_mode + + mode = hk_stock_source_mode() + if mode != "legacy": + rows = get_hk_stock_kline_rqdata(symbol, tf, lim, before_time) + if rows: + return self.filter_and_limit( + rows, + limit=lim, + before_time=before_time, + after_time=after_time, + truncate=(after_time is None), + ) + + # Tier 2: Twelve Data rows = fetch_twelvedata_klines( is_hk=True, tencent_code=code, timeframe=tf, limit=lim, before_time=before_time ) diff --git a/backend_api_python/app/data_sources/local_archive.py b/backend_api_python/app/data_sources/local_archive.py new file mode 100644 index 000000000..e8eb58806 --- /dev/null +++ b/backend_api_python/app/data_sources/local_archive.py @@ -0,0 +1,222 @@ +"""Host-side K-line archive under QUANTDINGER_DATE_DIR (default: repo DATE/). + +Layout: + DATE/stocks/CN/{SYMBOL}/{TIMEFRAME}.csv + DATE/futures/CN/{SYMBOL}/{TIMEFRAME}.csv + +CSV columns: time,open,high,low,close,volume (time = unix seconds) +""" +from __future__ import annotations + +import csv +import os +import time +from pathlib import Path +from typing import Any, Dict, Iterable, List, Optional, Tuple + +from app.utils.logger import get_logger + +logger = get_logger(__name__) + +CSV_FIELDS = ("time", "open", "high", "low", "close", "volume") +_ARCHIVED_MARKETS = frozenset({"CNStock", "USStock", "HKStock", "Futures"}) + + +def _env_flag(name: str, default: bool = True) -> bool: + raw = os.getenv(name) + if raw is None or not str(raw).strip(): + return default + return str(raw).strip().lower() in ("1", "true", "yes", "on") + + +def date_root() -> Path: + raw = (os.getenv("QUANTDINGER_DATE_DIR") or "").strip() + if raw: + return Path(raw) + # backend_api_python/app/data_sources/local_archive.py → repo root + return Path(__file__).resolve().parents[3] / "DATE" + + +def archive_enabled_read() -> bool: + return _env_flag("QUANTDINGER_DATE_READ", True) + + +def archive_enabled_write() -> bool: + return _env_flag("QUANTDINGER_DATE_WRITE", True) + + +def market_supported(market: str) -> bool: + return (market or "") in _ARCHIVED_MARKETS + + +def should_read(market: str, symbol: str) -> bool: + return archive_enabled_read() and archive_kind(market, symbol) is not None + + +def should_write(market: str, symbol: str) -> bool: + return archive_enabled_write() and archive_kind(market, symbol) is not None + + +def archive_kind(market: str, symbol: str) -> Optional[Tuple[str, str]]: + m = market or "" + if m == "CNStock": + return "stocks", "CN" + if m == "USStock": + return "stocks", "US" + if m == "HKStock": + return "stocks", "HK" + if m == "Futures": + from app.data_sources.cn_futures_symbols import is_cn_futures_symbol + + return "futures", "CN" if is_cn_futures_symbol(symbol) else "US" + return None + + +def canonical_symbol(market: str, symbol: str) -> str: + raw = (symbol or "").strip() + if market == "CNStock": + from app.data_sources.tencent import normalize_cn_code + + raw = normalize_cn_code(raw) or raw + cleaned = raw.upper().replace("/", "-").replace("\\", "-").replace("..", "") + return cleaned or "UNKNOWN" + + +def canonical_timeframe(timeframe: str) -> str: + tf = (timeframe or "1D").strip().upper().replace("/", "-") + return tf or "1D" + + +def kline_path(market: str, symbol: str, timeframe: str) -> Optional[Path]: + kind = archive_kind(market, symbol) + if kind is None: + return None + asset, region = kind + return ( + date_root() + / asset + / region + / canonical_symbol(market, symbol) + / f"{canonical_timeframe(timeframe)}.csv" + ) + + +def filter_klines( + klines: List[Dict[str, Any]], + limit: int, + before_time: Optional[int] = None, + after_time: Optional[int] = None, + truncate: bool = True, +) -> List[Dict[str, Any]]: + rows = sorted(klines, key=lambda x: int(x["time"])) + if before_time: + rows = [k for k in rows if int(k["time"]) < int(before_time)] + if after_time is not None: + rows = [k for k in rows if int(k["time"]) >= int(after_time)] + if truncate and limit and len(rows) > limit: + rows = rows[-limit:] + return rows + + +def local_covers( + rows: List[Dict[str, Any]], + *, + limit: int, + before_time: Optional[int], + after_time: Optional[int], + timeframe: str, +) -> bool: + if not rows: + return False + window = filter_klines( + rows, + limit=10**9, + before_time=before_time, + after_time=after_time, + truncate=False, + ) + if not window: + return False + if after_time is not None: + slack = 5 * 86400 + left_ok = int(window[0]["time"]) <= int(after_time) + slack + return left_ok and len(window) >= min(int(limit or 1), 20) + if len(window) < min(int(limit or 1), 30): + return False + last = int(window[-1]["time"]) + now = int(time.time()) + tf = canonical_timeframe(timeframe) + max_age = 10 * 86400 if tf in ("1D", "D", "1W", "W", "1M") else 2 * 86400 + return (now - last) <= max_age + + +def _as_bar(row: Dict[str, Any]) -> Optional[Dict[str, Any]]: + try: + ts = int(float(row["time"])) + return { + "time": ts, + "open": float(row.get("open") or 0), + "high": float(row.get("high") or 0), + "low": float(row.get("low") or 0), + "close": float(row.get("close") or 0), + "volume": float(row.get("volume") or 0), + } + except (KeyError, TypeError, ValueError): + return None + + +def read_klines(market: str, symbol: str, timeframe: str) -> List[Dict[str, Any]]: + path = kline_path(market, symbol, timeframe) + if path is None or not path.is_file(): + return [] + out: List[Dict[str, Any]] = [] + with path.open("r", encoding="utf-8", newline="") as fh: + reader = csv.DictReader(fh) + for raw in reader: + bar = _as_bar(raw) + if bar: + out.append(bar) + out.sort(key=lambda x: x["time"]) + return out + + +def merge_write( + market: str, + symbol: str, + timeframe: str, + incoming: Iterable[Dict[str, Any]], +) -> Path: + path = kline_path(market, symbol, timeframe) + if path is None: + raise ValueError(f"market {market} is not archived") + by_time: Dict[int, Dict[str, Any]] = {} + if path.is_file(): + for bar in read_klines(market, symbol, timeframe): + by_time[int(bar["time"])] = bar + added = 0 + for raw in incoming: + bar = _as_bar(raw) + if not bar: + continue + ts = int(bar["time"]) + if ts not in by_time: + added += 1 + by_time[ts] = bar + rows = [by_time[k] for k in sorted(by_time)] + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(path.suffix + ".tmp") + with tmp.open("w", encoding="utf-8", newline="") as fh: + writer = csv.DictWriter(fh, fieldnames=list(CSV_FIELDS)) + writer.writeheader() + for bar in rows: + writer.writerow({k: bar[k] for k in CSV_FIELDS}) + fh.flush() + os.fsync(fh.fileno()) + tmp.replace(path) + logger.debug( + "DATE archive wrote %s bars (+%s new) → %s", + len(rows), + added, + path, + ) + return path diff --git a/backend_api_python/app/data_sources/rqdata_equity.py b/backend_api_python/app/data_sources/rqdata_equity.py new file mode 100644 index 000000000..b0b647977 --- /dev/null +++ b/backend_api_python/app/data_sources/rqdata_equity.py @@ -0,0 +1,173 @@ +"""Ricequant RQData adapters for A-share and HK equity K-lines.""" +from __future__ import annotations + +import os +from datetime import datetime +from typing import Any, Dict, List, Optional + +from app.data_sources.base import TIMEFRAME_SECONDS +from app.data_sources.rqdata_futures import ( + _dataframe_to_klines, + _import_rqdatac, + _lookback_start, + _resample_ohlcv, + ensure_rqdata_initialized, + normalize_cn_futures_timeframe, +) +from app.data_sources.tencent import normalize_cn_code, normalize_hk_code +from app.utils.logger import get_logger + +logger = get_logger(__name__) + + +def cn_stock_source_mode() -> str: + return (os.getenv("CN_STOCK_PROVIDER") or "rqdata").strip().lower() or "rqdata" + + +def hk_stock_source_mode() -> str: + return (os.getenv("HK_STOCK_PROVIDER") or "rqdata").strip().lower() or "rqdata" + + +def is_cn_index_code(tencent_code: str) -> bool: + code = (tencent_code or "").strip().upper() + if code.startswith("SH") and code[2:].isdigit() and code[2:].startswith("000"): + return True + if code.startswith("SZ") and code[2:].isdigit() and code[2:].startswith("399"): + return True + return False + + +def to_rqdata_cn_equity_id(symbol: str) -> str: + raw = (symbol or "").strip().upper() + if raw.endswith(".XSHG") or raw.endswith(".XSHE"): + return raw + code = normalize_cn_code(symbol) + digits = code[2:] if len(code) > 2 else raw + if code.startswith("SH") and digits.isdigit(): + return f"{digits}.XSHG" + if code.startswith("SZ") and digits.isdigit(): + return f"{digits}.XSHE" + return raw + + +def to_rqdata_hk_equity_id(symbol: str) -> str: + raw = (symbol or "").strip().upper() + if raw.endswith(".XHKG"): + return raw + code = normalize_hk_code(symbol) + digits = code[2:] if code.startswith("HK") else raw + if digits.isdigit(): + return f"{digits.zfill(5)}.XHKG" + return raw + + +def _get_price_klines( + order_book_id: str, + timeframe: str, + limit: int, + before_time: Optional[int] = None, + *, + adjust_type: str = "pre", +) -> List[Dict[str, Any]]: + if not ensure_rqdata_initialized(): + return [] + rqdatac = _import_rqdatac() + timeframe = normalize_cn_futures_timeframe(timeframe) + want_4h = timeframe == "4H" + frequency = { + "1m": "1m", + "5m": "5m", + "15m": "15m", + "30m": "30m", + "1H": "60m", + "1D": "1d", + "1W": "1w", + }.get("1H" if want_4h else timeframe) + if not frequency: + return [] + end_dt = datetime.now() + if before_time: + end_dt = datetime.fromtimestamp(int(before_time)) + fetch_limit = max(int(limit) * 4, int(limit)) if want_4h else max(int(limit), 1) + start_dt = _lookback_start(end_dt, "1H" if want_4h else timeframe, fetch_limit) + try: + df = rqdatac.get_price( + order_book_id, + start_date=start_dt.strftime("%Y-%m-%d"), + end_date=end_dt.strftime("%Y-%m-%d"), + frequency=frequency, + fields=["open", "high", "low", "close", "volume"], + adjust_type=adjust_type, + expect_df=True, + ) + except Exception as exc: + logger.warning("RQData get_price failed %s: %s", order_book_id, exc) + return [] + klines = _dataframe_to_klines(df) + if want_4h: + klines = _resample_ohlcv(klines, TIMEFRAME_SECONDS["4H"]) + if before_time: + klines = [k for k in klines if k["time"] <= int(before_time)] + if limit and len(klines) > limit: + klines = klines[-limit:] + return klines + + +def get_cn_stock_kline_rqdata( + symbol: str, + timeframe: str, + limit: int, + before_time: Optional[int] = None, +) -> List[Dict[str, Any]]: + order_book_id = to_rqdata_cn_equity_id(symbol) + tencent_code = normalize_cn_code(symbol) + adjust = "none" if is_cn_index_code(tencent_code) else "pre" + bars = _get_price_klines( + order_book_id, timeframe, limit, before_time, adjust_type=adjust + ) + if bars: + logger.debug("RQData CN equity %s -> %s %s: %d bars", symbol, order_book_id, timeframe, len(bars)) + return bars + + +def get_hk_stock_kline_rqdata( + symbol: str, + timeframe: str, + limit: int, + before_time: Optional[int] = None, +) -> List[Dict[str, Any]]: + order_book_id = to_rqdata_hk_equity_id(symbol) + bars = _get_price_klines(order_book_id, timeframe, limit, before_time, adjust_type="pre") + if bars: + logger.debug("RQData HK equity %s -> %s %s: %d bars", symbol, order_book_id, timeframe, len(bars)) + return bars + + +def _ticker_from_bars(symbol: str, bars: List[Dict[str, Any]]) -> Dict[str, Any]: + if not bars: + return {"symbol": symbol, "last": 0.0} + last = float(bars[-1]["close"]) + prev = float(bars[-2]["close"]) if len(bars) > 1 else last + change = last - prev if prev else 0.0 + change_pct = (change / prev * 100) if prev else 0.0 + return { + "symbol": symbol, + "last": round(last, 4), + "change": round(change, 4), + "changePercent": round(change_pct, 2), + "open": round(float(bars[-1]["open"]), 4), + "high": round(float(bars[-1]["high"]), 4), + "low": round(float(bars[-1]["low"]), 4), + "previousClose": round(prev, 4), + "source": "rqdata", + } + + +def get_cn_stock_ticker_rqdata(symbol: str) -> Dict[str, Any]: + bars = get_cn_stock_kline_rqdata(symbol, "1D", 3) + return _ticker_from_bars(symbol, bars) + + +def get_hk_stock_ticker_rqdata(symbol: str) -> Dict[str, Any]: + bars = get_hk_stock_kline_rqdata(symbol, "1D", 3) + return _ticker_from_bars(symbol, bars) diff --git a/backend_api_python/app/data_sources/rqdata_futures.py b/backend_api_python/app/data_sources/rqdata_futures.py new file mode 100644 index 000000000..124b51031 --- /dev/null +++ b/backend_api_python/app/data_sources/rqdata_futures.py @@ -0,0 +1,260 @@ +"""Ricequant RQData adapter for Chinese domestic futures research bars.""" +from __future__ import annotations + +import math +import os +import threading +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, List, Optional + +from app.data_sources.base import TIMEFRAME_SECONDS +from app.data_sources.cn_futures_symbols import to_rqdata_order_book_id +from app.utils.logger import get_logger + +logger = get_logger(__name__) + +_INIT_LOCK = threading.Lock() +_INITIALIZED = False +_INIT_ERROR: Optional[str] = None + +_RQ_FREQUENCY = { + "1m": "1m", + "5m": "5m", + "15m": "15m", + "30m": "30m", + "1H": "60m", + "1D": "1d", + "1W": "1w", +} + +_TF_ALIASES = { + "1h": "1H", + "4h": "4H", + "1d": "1D", + "1w": "1W", + "60m": "1H", + "240m": "4H", + "1hour": "1H", + "1day": "1D", + "1week": "1W", +} + + +def normalize_cn_futures_timeframe(timeframe: str) -> str: + raw = (timeframe or "1D").strip() + if not raw: + return "1D" + if raw in _RQ_FREQUENCY or raw == "4H": + return raw + return _TF_ALIASES.get(raw.lower(), raw) + + +def cn_futures_source_mode() -> str: + return (os.getenv("FUTURES_CN_PROVIDER") or "rqdata").strip().lower() or "rqdata" + + +def rqdata_configured() -> bool: + return bool( + (os.getenv("RQDATAC_LICENSE") or "").strip() + or (os.getenv("RQDATAC_URI") or "").strip() + or ((os.getenv("RQDATAC_USERNAME") or "").strip() and (os.getenv("RQDATAC_PASSWORD") or "").strip()) + ) + + +def _import_rqdatac(): + try: + import rqdatac # type: ignore + return rqdatac + except Exception as exc: + raise RuntimeError("rqdatac is not installed") from exc + + +def ensure_rqdata_initialized() -> bool: + """Initialize RQData once per process. Returns False when unavailable.""" + global _INITIALIZED, _INIT_ERROR + if _INITIALIZED: + return True + with _INIT_LOCK: + if _INITIALIZED: + return True + try: + rqdatac = _import_rqdatac() + uri = (os.getenv("RQDATAC_URI") or "").strip() + license_key = (os.getenv("RQDATAC_LICENSE") or "").strip() + username = (os.getenv("RQDATAC_USERNAME") or "").strip() + password = (os.getenv("RQDATAC_PASSWORD") or "").strip() + if uri: + rqdatac.init(uri=uri) + elif license_key: + try: + rqdatac.init("license", license_key) + except TypeError: + rqdatac.init(username="license", password=license_key) + elif username and password: + rqdatac.init(username, password) + else: + rqdatac.init() + _INITIALIZED = True + _INIT_ERROR = None + logger.info("RQData initialized") + return True + except Exception as exc: + _INIT_ERROR = str(exc) + logger.warning("RQData init failed: %s", exc) + return False + + +def _lookback_start(end_dt: datetime, timeframe: str, fetch_limit: int) -> datetime: + """Calendar start for session-based CN futures (nights, weekends, holidays).""" + tf = normalize_cn_futures_timeframe(timeframe) + limit = max(int(fetch_limit), 1) + tf_seconds = TIMEFRAME_SECONDS.get(tf, 86400) + if tf == "1W": + days = limit * 8 + 14 + elif tf == "1D": + days = int(limit * 1.8) + 14 + else: + # ~4 liquid hours/day is a conservative floor for commodity sessions. + days = int(math.ceil(limit * tf_seconds / (4 * 3600))) + 14 + days = max(10, min(int(days), 2500)) + return end_dt - timedelta(days=days) + + +def _numeric(value, default: float = 0.0) -> float: + try: + number = float(value) + except (TypeError, ValueError): + return default + if number != number or number in (float("inf"), float("-inf")): + return default + return number + + +def _bar_timestamp(value) -> Optional[int]: + if value is None: + return None + try: + import pandas as pd + + ts = pd.Timestamp(value) + if ts.tzinfo is None: + ts = ts.tz_localize("Asia/Shanghai") + return int(ts.timestamp()) + except Exception: + if isinstance(value, datetime): + if value.tzinfo is None: + value = value.replace(tzinfo=timezone(timedelta(hours=8))) + return int(value.timestamp()) + return None + + +def _resample_ohlcv(bars: List[Dict[str, Any]], bucket_seconds: int) -> List[Dict[str, Any]]: + if not bars or bucket_seconds <= 0: + return bars + grouped: Dict[int, List[Dict[str, Any]]] = {} + for bar in bars: + key = int(bar["time"]) // bucket_seconds * bucket_seconds + grouped.setdefault(key, []).append(bar) + out = [] + for key in sorted(grouped): + chunk = grouped[key] + out.append({ + "time": key, + "open": float(chunk[0]["open"]), + "high": max(float(b["high"]) for b in chunk), + "low": min(float(b["low"]) for b in chunk), + "close": float(chunk[-1]["close"]), + "volume": float(sum(float(b["volume"]) for b in chunk)), + }) + return out + + +def _dataframe_to_klines(df) -> List[Dict[str, Any]]: + if df is None or getattr(df, "empty", True): + return [] + work = df.reset_index() + time_col = None + for candidate in ("datetime", "date", "index", work.columns[0]): + if candidate in work.columns: + time_col = candidate + break + klines = [] + for _, row in work.iterrows(): + ts = _bar_timestamp(row.get(time_col)) + if ts is None: + continue + klines.append({ + "time": ts, + "open": _numeric(row.get("open")), + "high": _numeric(row.get("high")), + "low": _numeric(row.get("low")), + "close": _numeric(row.get("close")), + "volume": _numeric(row.get("volume")), + }) + klines.sort(key=lambda x: x["time"]) + return klines + + +def get_cn_futures_kline_rqdata( + symbol: str, + timeframe: str, + limit: int, + before_time: Optional[int] = None, +) -> List[Dict[str, Any]]: + if not ensure_rqdata_initialized(): + return [] + rqdatac = _import_rqdatac() + order_book_id = to_rqdata_order_book_id(symbol) + timeframe = normalize_cn_futures_timeframe(timeframe) + want_4h = timeframe == "4H" + frequency = _RQ_FREQUENCY.get("1H" if want_4h else timeframe) + if not frequency: + return [] + end_dt = datetime.now() + if before_time: + end_dt = datetime.fromtimestamp(int(before_time)) + fetch_limit = max(int(limit) * 4, int(limit)) if want_4h else max(int(limit), 1) + start_dt = _lookback_start(end_dt, "1H" if want_4h else timeframe, fetch_limit) + try: + df = rqdatac.get_price( + order_book_id, + start_date=start_dt.strftime("%Y-%m-%d"), + end_date=end_dt.strftime("%Y-%m-%d"), + frequency=frequency, + fields=["open", "high", "low", "close", "volume"], + adjust_type="none", + expect_df=True, + ) + except Exception as exc: + logger.warning("RQData get_price failed %s (%s): %s", symbol, order_book_id, exc) + return [] + klines = _dataframe_to_klines(df) + if want_4h: + klines = _resample_ohlcv(klines, TIMEFRAME_SECONDS["4H"]) + if before_time: + klines = [k for k in klines if k["time"] <= int(before_time)] + if limit and len(klines) > limit: + klines = klines[-limit:] + if klines: + logger.debug("RQData futures kline %s -> %s %s: %d bars", symbol, order_book_id, timeframe, len(klines)) + return klines + + +def get_cn_futures_ticker_rqdata(symbol: str) -> Dict[str, Any]: + bars = get_cn_futures_kline_rqdata(symbol, "1m", 8) + if len(bars) < 1: + bars = get_cn_futures_kline_rqdata(symbol, "1D", 3) + if not bars: + return {"symbol": symbol, "last": 0.0} + last = float(bars[-1]["close"]) + prev = float(bars[-2]["close"]) if len(bars) > 1 else last + change = last - prev if prev else 0.0 + change_pct = (change / prev * 100) if prev else 0.0 + return { + "symbol": symbol, + "last": round(last, 4), + "change": round(change, 4), + "changePercent": round(change_pct, 2), + "previousClose": round(prev, 4), + "source": "rqdata", + } diff --git a/backend_api_python/app/markets/registry.py b/backend_api_python/app/markets/registry.py index edb3162bc..6df45f71c 100644 --- a/backend_api_python/app/markets/registry.py +++ b/backend_api_python/app/markets/registry.py @@ -106,11 +106,18 @@ base_currency="CNY", features=["research", "backtest", "paper"], data_requirements=[ + DataRequirement( + key="ricequant", + label="Ricequant RQData", + setting_keys=["RQDATAC_LICENSE"], + recommended=True, + purpose="A-share quotes and K-lines", + ), DataRequirement( key="akshare", label="AkShare fallback", built_in=True, - purpose="quotes and OHLCV", + purpose="quotes and OHLCV fallback", ), DataRequirement( key="twelve_data", @@ -130,6 +137,13 @@ base_currency="HKD", features=["research", "backtest", "paper"], data_requirements=[ + DataRequirement( + key="ricequant", + label="Ricequant RQData", + setting_keys=["RQDATAC_LICENSE"], + recommended=True, + purpose="Hong Kong quotes and K-lines", + ), DataRequirement( key="yfinance", label="Yahoo Finance fallback", @@ -190,7 +204,14 @@ key="twelve_data", label="Twelve Data", setting_keys=["TWELVE_DATA_API_KEY"], - purpose="futures quotes and K-lines", + purpose="US/global futures quotes and K-lines", + ), + DataRequirement( + key="ricequant", + label="Ricequant RQData", + setting_keys=["RQDATAC_LICENSE"], + recommended=True, + purpose="Chinese domestic futures quotes and K-lines", ), ], supports={"spot": False, "swap": False, "short": True, "session": "exchange-hours"}, diff --git a/backend_api_python/env.example b/backend_api_python/env.example index 5873e24a9..15804d0af 100644 --- a/backend_api_python/env.example +++ b/backend_api_python/env.example @@ -567,6 +567,19 @@ SEC_USER_AGENT=QuantDinger/5.0 open-source-research support@your-domain.com TIINGO_API_KEY= TIINGO_TIMEOUT=10 +# Chinese equities and domestic futures: Ricequant RQData first, then legacy fallbacks. +# Install the licensed client separately: pip install rqdatac +# CN_STOCK_PROVIDER=rqdata|legacy +# HK_STOCK_PROVIDER=rqdata|legacy +# FUTURES_CN_PROVIDER=rqdata|auto|akshare|rqdata-only +CN_STOCK_PROVIDER=rqdata +HK_STOCK_PROVIDER=rqdata +FUTURES_CN_PROVIDER=rqdata +RQDATAC_LICENSE= +# RQDATAC_URI= +# RQDATAC_USERNAME= +# RQDATAC_PASSWORD= + # Crypto derivatives / on-chain data (optional, used by some AI prompts) COINGLASS_API_KEY= CRYPTOQUANT_API_KEY= @@ -669,6 +682,13 @@ REDIS_DB=0 REDIS_CACHE_NAMESPACE=quantdinger:cache:v1 CACHE_ENABLED=true +# Host-side K-line archive (stocks + futures). Docker bind-mounts ./DATE → /app/DATE. +# CSV layout: DATE/stocks/CN/{SYMBOL}/1D.csv and DATE/futures/CN/{SYMBOL}/1D.csv +# Bulk download: python scripts/download_date_history.py +QUANTDINGER_DATE_DIR=/app/DATE +QUANTDINGER_DATE_READ=true +QUANTDINGER_DATE_WRITE=true + # Internal INTERNAL_API_KEY= diff --git a/backend_api_python/scripts/download_date_history.py b/backend_api_python/scripts/download_date_history.py new file mode 100644 index 000000000..f1927481f --- /dev/null +++ b/backend_api_python/scripts/download_date_history.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +"""Download CN stock and CN futures history into the DATE archive. + +Uses the same DataSourceFactory path as the running backend. Local CSV +read is disabled for this process so each run refreshes from remote and +merges into DATE/. + +Examples (from backend_api_python, or inside the backend container): + + python scripts/download_date_history.py + python scripts/download_date_history.py --market stocks --limit 1500 + python scripts/download_date_history.py --market futures --symbols RB0,SC0,IF0 + python scripts/download_date_history.py --universe DATE/universe.json +""" +from __future__ import annotations + +import argparse +import json +import os +import sys +import time +from typing import Any, Dict, List + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +os.environ.setdefault("SECRET_KEY", "download-date-history") +os.environ.setdefault("ADMIN_USER", "download") +os.environ.setdefault("ADMIN_PASSWORD", "download-pass") +# Always hit remote providers; still write CSV via QUANTDINGER_DATE_WRITE. +os.environ["QUANTDINGER_DATE_READ"] = "false" +os.environ.setdefault("QUANTDINGER_DATE_WRITE", "true") + +from app.data_sources.factory import DataSourceFactory # noqa: E402 +from app.data_sources.local_archive import date_root, kline_path, read_klines # noqa: E402 + +DEFAULT_STOCKS = [ + "000001", + "000333", + "000725", + "000858", + "002415", + "002594", + "300750", + "600036", + "600276", + "600519", + "600900", + "601012", + "601166", + "601318", + "601899", + "000001.SH", + "000300.SH", + "000016.SH", + "399001.SZ", + "399006.SZ", +] + +DEFAULT_FUTURES = [ + "RB0", + "HC0", + "I0", + "CU0", + "AU0", + "AG0", + "SC0", + "M0", + "C0", + "CF0", + "TA0", + "MA0", + "IF0", + "IH0", + "IC0", + "IM0", +] + + +def _parse_symbols(raw: str) -> List[str]: + return [part.strip() for part in (raw or "").split(",") if part.strip()] + + +def _load_universe(path: str) -> Dict[str, List[str]]: + with open(path, "r", encoding="utf-8") as fh: + data = json.load(fh) + stocks = data.get("CNStock") or data.get("stocks") or [] + futures = data.get("Futures") or data.get("futures") or [] + return {"CNStock": list(stocks), "Futures": list(futures)} + + +def _download_one(market: str, symbol: str, timeframe: str, limit: int) -> Dict[str, Any]: + rows = DataSourceFactory.get_kline(market, symbol, timeframe, limit) + path = kline_path(market, symbol, timeframe) + stored = read_klines(market, symbol, timeframe) if path else [] + return { + "market": market, + "symbol": symbol, + "fetched": len(rows), + "stored": len(stored), + "path": str(path) if path else "", + "ok": bool(rows), + } + + +def main() -> int: + parser = argparse.ArgumentParser(description="Download stock/futures history into DATE/") + parser.add_argument("--market", choices=("all", "stocks", "futures"), default="all") + parser.add_argument("--timeframe", default="1D") + parser.add_argument("--limit", type=int, default=1500) + parser.add_argument("--symbols", default="", help="Comma-separated symbols; overrides defaults") + parser.add_argument("--universe", default="", help="JSON file with CNStock / Futures lists") + parser.add_argument("--sleep", type=float, default=0.35, help="Pause between symbols (seconds)") + args = parser.parse_args() + + stocks = list(DEFAULT_STOCKS) + futures = list(DEFAULT_FUTURES) + if args.universe: + loaded = _load_universe(args.universe) + stocks = loaded["CNStock"] or stocks + futures = loaded["Futures"] or futures + if args.symbols: + picked = _parse_symbols(args.symbols) + if args.market == "futures": + futures = picked + elif args.market == "stocks": + stocks = picked + else: + stocks = picked + futures = picked + + jobs: List[tuple[str, str]] = [] + if args.market in ("all", "stocks"): + jobs.extend(("CNStock", s) for s in stocks) + if args.market in ("all", "futures"): + jobs.extend(("Futures", s) for s in futures) + + root = date_root() + root.mkdir(parents=True, exist_ok=True) + print(f"DATE root: {root}") + print(f"jobs={len(jobs)} timeframe={args.timeframe} limit={args.limit}") + + failed = 0 + for i, (market, symbol) in enumerate(jobs, start=1): + try: + result = _download_one(market, symbol, args.timeframe, args.limit) + except Exception as exc: + failed += 1 + print(f"[{i}/{len(jobs)}] {market} {symbol} ERROR {exc}") + continue + status = "ok" if result["ok"] else "empty" + if not result["ok"]: + failed += 1 + print( + f"[{i}/{len(jobs)}] {status:5} {market:8} {symbol:12} " + f"fetched={result['fetched']:<5} stored={result['stored']:<5} {result['path']}" + ) + if args.sleep and i < len(jobs): + time.sleep(args.sleep) + + print(f"done failed={failed}/{len(jobs)}") + return 1 if failed else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend_api_python/tests/test_local_archive.py b/backend_api_python/tests/test_local_archive.py new file mode 100644 index 000000000..659584f5e --- /dev/null +++ b/backend_api_python/tests/test_local_archive.py @@ -0,0 +1,95 @@ +"""Local DATE/ K-line archive read, merge, and factory prefer-local.""" +from __future__ import annotations + +from pathlib import Path + +import pytest + +from app.data_sources import local_archive as archive +from app.data_sources.factory import DataSourceFactory + + +def _bars(start: int, n: int, step: int = 86400): + rows = [] + for i in range(n): + ts = start + i * step + px = 10.0 + i + rows.append( + { + "time": ts, + "open": px, + "high": px + 1, + "low": px - 1, + "close": px + 0.5, + "volume": 1000 + i, + } + ) + return rows + + +@pytest.fixture +def date_dir(tmp_path, monkeypatch): + root = tmp_path / "DATE" + monkeypatch.setenv("QUANTDINGER_DATE_DIR", str(root)) + monkeypatch.setenv("QUANTDINGER_DATE_READ", "true") + monkeypatch.setenv("QUANTDINGER_DATE_WRITE", "true") + return root + + +def test_merge_write_and_read_roundtrip(date_dir: Path): + first = _bars(1_700_000_000, 3) + path = archive.merge_write("CNStock", "600519", "1D", first) + assert path.is_file() + assert "stocks" in str(path).replace("\\", "/") + assert "CN" in str(path).replace("\\", "/") + + extra = _bars(1_700_000_000 + 2 * 86400, 2) + extra[0]["close"] = 99.0 + archive.merge_write("CNStock", "600519", "1D", extra) + stored = archive.read_klines("CNStock", "sh600519", "1D") + assert [row["time"] for row in stored] == [ + 1_700_000_000, + 1_700_000_000 + 86400, + 1_700_000_000 + 2 * 86400, + 1_700_000_000 + 3 * 86400, + ] + assert stored[2]["close"] == 99.0 + + +def test_factory_prefers_fresh_local(date_dir: Path, monkeypatch): + now = 1_800_000_000 + monkeypatch.setattr(archive.time, "time", lambda: now) + local = _bars(now - 40 * 86400, 40) + archive.merge_write("CNStock", "000001", "1D", local) + + def _boom(cls, *args, **kwargs): + raise AssertionError("remote source should not be called") + + monkeypatch.setattr(DataSourceFactory, "_resolve_source", classmethod(_boom)) + + rows = DataSourceFactory.get_kline("CNStock", "000001", "1D", 30) + assert len(rows) == 30 + assert rows[-1]["time"] == local[-1]["time"] + + +def test_factory_writes_remote_rows(date_dir: Path, monkeypatch): + remote = _bars(1_710_000_000, 5) + + class _Src: + def get_kline(self, *_args, **_kwargs): + return list(remote) + + monkeypatch.setattr( + DataSourceFactory, + "_resolve_source", + classmethod(lambda cls, *a, **k: _Src()), + ) + monkeypatch.setenv("QUANTDINGER_DATE_READ", "false") + + rows = DataSourceFactory.get_kline("Futures", "RB0", "1D", 5) + assert len(rows) == 5 + stored = archive.read_klines("Futures", "RB0", "1D") + assert len(stored) == 5 + path = archive.kline_path("Futures", "RB0", "1D") + assert path is not None + assert "futures" in str(path).replace("\\", "/") diff --git a/backend_api_python/tests/test_rqdata_equity.py b/backend_api_python/tests/test_rqdata_equity.py new file mode 100644 index 000000000..fe456a54d --- /dev/null +++ b/backend_api_python/tests/test_rqdata_equity.py @@ -0,0 +1,29 @@ +from app.data_sources.rqdata_equity import ( + is_cn_index_code, + to_rqdata_cn_equity_id, + to_rqdata_hk_equity_id, +) + + +def test_cn_stock_ids(): + assert to_rqdata_cn_equity_id("600519") == "600519.XSHG" + assert to_rqdata_cn_equity_id("000001") == "000001.XSHE" + assert to_rqdata_cn_equity_id("000001.SH") == "000001.XSHG" + assert to_rqdata_cn_equity_id("300750") == "300750.XSHE" + assert to_rqdata_cn_equity_id("000300.SH") == "000300.XSHG" + assert to_rqdata_cn_equity_id("399006.SZ") == "399006.XSHE" + assert to_rqdata_cn_equity_id("600519.XSHG") == "600519.XSHG" + + +def test_index_adjust_flag(): + assert is_cn_index_code("SH000001") + assert is_cn_index_code("SH000300") + assert is_cn_index_code("SZ399006") + assert not is_cn_index_code("SZ000001") + assert not is_cn_index_code("SH600519") + + +def test_hk_ids(): + assert to_rqdata_hk_equity_id("0700.HK") == "00700.XHKG" + assert to_rqdata_hk_equity_id("700") == "00700.XHKG" + assert to_rqdata_hk_equity_id("00700.XHKG") == "00700.XHKG" diff --git a/backend_api_python/tests/test_rqdata_futures.py b/backend_api_python/tests/test_rqdata_futures.py new file mode 100644 index 000000000..d216058c3 --- /dev/null +++ b/backend_api_python/tests/test_rqdata_futures.py @@ -0,0 +1,68 @@ +from datetime import datetime +from app.data_sources.cn_futures_symbols import ( + is_cn_futures_symbol, + to_rqdata_order_book_id, +) +from app.data_sources.rqdata_futures import ( + _lookback_start, + _resample_ohlcv, + normalize_cn_futures_timeframe, +) + + +def test_cn_futures_symbol_accepts_sina_and_ricequant_ids(): + assert is_cn_futures_symbol("IF2609") + assert is_cn_futures_symbol("RB0") + assert is_cn_futures_symbol("SC2611") + assert is_cn_futures_symbol("IF2609.CFE") + assert is_cn_futures_symbol("IF88") + assert not is_cn_futures_symbol("ES") + assert not is_cn_futures_symbol("BTC/USDT") + + +def test_rqdata_order_book_id_mapping(): + assert to_rqdata_order_book_id("IF2609") == "IF2609" + assert to_rqdata_order_book_id("RB0") == "RB88" + assert to_rqdata_order_book_id("SC2611") == "SC2611" + assert to_rqdata_order_book_id("M2505") == "M2505" + assert to_rqdata_order_book_id("IF2609.CFE") == "IF2609" + assert to_rqdata_order_book_id("CF609") == "CF2609" + + +def test_suggest_cn_continuous_symbols(): + from app.data_sources.cn_futures_symbols import suggest_cn_continuous_symbols + + assert "RB0" in suggest_cn_continuous_symbols("RB") + assert "SI0" in suggest_cn_continuous_symbols("SI") + assert "ZC0" in suggest_cn_continuous_symbols("ZC") + assert "IF0" in suggest_cn_continuous_symbols("IF0") + + +def test_resample_ohlcv_4h_buckets(): + bars = [ + {"time": 0, "open": 1, "high": 2, "low": 1, "close": 1.5, "volume": 10}, + {"time": 3600, "open": 1.5, "high": 3, "low": 1.4, "close": 2, "volume": 20}, + {"time": 14400, "open": 2, "high": 2.2, "low": 1.9, "close": 2.1, "volume": 5}, + ] + out = _resample_ohlcv(bars, 14400) + assert len(out) == 2 + assert out[0]["open"] == 1 + assert out[0]["high"] == 3 + assert out[0]["low"] == 1 + assert out[0]["close"] == 2 + assert out[0]["volume"] == 30 + + +def test_normalize_cn_futures_timeframe_aliases(): + assert normalize_cn_futures_timeframe("1h") == "1H" + assert normalize_cn_futures_timeframe("4h") == "4H" + assert normalize_cn_futures_timeframe("1d") == "1D" + assert normalize_cn_futures_timeframe("60m") == "1H" + + +def test_lookback_covers_session_gaps_for_hourly_charts(): + end = datetime(2026, 9, 12, 12, 0, 0) + start = _lookback_start(end, "1H", 300) + assert (end - start).days >= 80 + daily_start = _lookback_start(end, "1D", 5) + assert 10 <= (end - daily_start).days <= 40 diff --git a/docker-compose.ghcr.yml b/docker-compose.ghcr.yml index f2d2fd528..ee3dbdb59 100644 --- a/docker-compose.ghcr.yml +++ b/docker-compose.ghcr.yml @@ -136,6 +136,7 @@ services: volumes: - backend_logs:/app/logs - backend_data:/app/data + - ./DATE:/app/DATE - ./backend.env:/app/.env environment: - DATABASE_URL=postgresql://${POSTGRES_USER:-quantdinger}:${POSTGRES_PASSWORD:-quantdinger123}@postgres:5432/${POSTGRES_DB:-quantdinger} @@ -170,6 +171,7 @@ services: volumes: - backend_logs:/app/logs - backend_data:/app/data + - ./DATE:/app/DATE # Backend runtime config. Must exist as a *file* on the host - if missing, # docker silently creates a directory at the mount target and shadows # /app/.env. The entrypoint will auto-generate SECRET_KEY on first start @@ -188,6 +190,12 @@ services: - REDIS_PASSWORD=${REDIS_PASSWORD:-} - REDIS_CACHE_NAMESPACE=${REDIS_CACHE_NAMESPACE:-quantdinger:cache:v1} - CACHE_ENABLED=true + - QUANTDINGER_DATE_DIR=${QUANTDINGER_DATE_DIR:-/app/DATE} + - QUANTDINGER_DATE_READ=${QUANTDINGER_DATE_READ:-true} + - QUANTDINGER_DATE_WRITE=${QUANTDINGER_DATE_WRITE:-true} + - CN_STOCK_PROVIDER=${CN_STOCK_PROVIDER:-rqdata} + - HK_STOCK_PROVIDER=${HK_STOCK_PROVIDER:-rqdata} + - FUTURES_CN_PROVIDER=${FUTURES_CN_PROVIDER:-rqdata} - QD_PROCESS_ROLE=api - LOG_FORMAT=${LOG_FORMAT:-json} - PROMETHEUS_MULTIPROC_DIR=/tmp/quantdinger-prometheus @@ -238,6 +246,7 @@ services: volumes: - backend_logs:/app/logs - backend_data:/app/data + - ./DATE:/app/DATE - ./backend.env:/app/.env environment: - QD_PROCESS_ROLE=trading @@ -249,6 +258,12 @@ services: - REDIS_PASSWORD=${REDIS_PASSWORD:-} - REDIS_CACHE_NAMESPACE=${REDIS_CACHE_NAMESPACE:-quantdinger:cache:v1} - CACHE_ENABLED=true + - QUANTDINGER_DATE_DIR=${QUANTDINGER_DATE_DIR:-/app/DATE} + - QUANTDINGER_DATE_READ=${QUANTDINGER_DATE_READ:-true} + - QUANTDINGER_DATE_WRITE=${QUANTDINGER_DATE_WRITE:-true} + - CN_STOCK_PROVIDER=${CN_STOCK_PROVIDER:-rqdata} + - HK_STOCK_PROVIDER=${HK_STOCK_PROVIDER:-rqdata} + - FUTURES_CN_PROVIDER=${FUTURES_CN_PROVIDER:-rqdata} - DB_POOL_MIN=${TRADING_DB_POOL_MIN:-4} - DB_POOL_MAX=${TRADING_DB_POOL_MAX:-32} - SKIP_AUTO_MIGRATE=true @@ -279,6 +294,7 @@ services: volumes: - backend_logs:/app/logs - backend_data:/app/data + - ./DATE:/app/DATE - ./backend.env:/app/.env environment: - QD_PROCESS_ROLE=scheduler @@ -289,6 +305,12 @@ services: - REDIS_PASSWORD=${REDIS_PASSWORD:-} - REDIS_CACHE_NAMESPACE=${REDIS_CACHE_NAMESPACE:-quantdinger:cache:v1} - CACHE_ENABLED=true + - QUANTDINGER_DATE_DIR=${QUANTDINGER_DATE_DIR:-/app/DATE} + - QUANTDINGER_DATE_READ=${QUANTDINGER_DATE_READ:-true} + - QUANTDINGER_DATE_WRITE=${QUANTDINGER_DATE_WRITE:-true} + - CN_STOCK_PROVIDER=${CN_STOCK_PROVIDER:-rqdata} + - HK_STOCK_PROVIDER=${HK_STOCK_PROVIDER:-rqdata} + - FUTURES_CN_PROVIDER=${FUTURES_CN_PROVIDER:-rqdata} - DB_POOL_MIN=${WORKER_DB_POOL_MIN:-2} - DB_POOL_MAX=${WORKER_DB_POOL_MAX:-12} - SKIP_AUTO_MIGRATE=true @@ -318,6 +340,7 @@ services: volumes: - backend_logs:/app/logs - backend_data:/app/data + - ./DATE:/app/DATE - ./backend.env:/app/.env environment: - QD_PROCESS_ROLE=celery @@ -362,6 +385,7 @@ services: volumes: - backend_logs:/app/logs - backend_data:/app/data + - ./DATE:/app/DATE - ./backend.env:/app/.env environment: - QD_PROCESS_ROLE=celery diff --git a/docker-compose.yml b/docker-compose.yml index 2e2b1b03a..dd1b78734 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -39,6 +39,8 @@ x-backend-build: &backend-build x-backend-runtime: &backend-runtime init: true + env_file: + - ./backend.env security_opt: - no-new-privileges:true pids_limit: ${BACKEND_PIDS_LIMIT:-512} @@ -75,6 +77,12 @@ x-worker-environment: &worker-environment ALLOW_LOCAL_DESKTOP_BROKERS: ${ALLOW_LOCAL_DESKTOP_BROKERS:-true} MARKET_CATALOG_AUTO_SYNC: ${MARKET_CATALOG_AUTO_SYNC:-true} MARKET_CATALOG_SYNC_INTERVAL_SEC: ${MARKET_CATALOG_SYNC_INTERVAL_SEC:-86400} + QUANTDINGER_DATE_DIR: ${QUANTDINGER_DATE_DIR:-/app/DATE} + QUANTDINGER_DATE_READ: ${QUANTDINGER_DATE_READ:-true} + QUANTDINGER_DATE_WRITE: ${QUANTDINGER_DATE_WRITE:-true} + CN_STOCK_PROVIDER: ${CN_STOCK_PROVIDER:-rqdata} + HK_STOCK_PROVIDER: ${HK_STOCK_PROVIDER:-rqdata} + FUTURES_CN_PROVIDER: ${FUTURES_CN_PROVIDER:-rqdata} services: # ======================== @@ -207,6 +215,7 @@ services: volumes: - backend_logs:/app/logs - backend_data:/app/data + - ./DATE:/app/DATE # Mount .env for runtime config and admin-panel updates - ./backend_api_python/.env:/app/.env environment: @@ -221,6 +230,12 @@ services: - REDIS_PASSWORD=${REDIS_PASSWORD:-} - REDIS_CACHE_NAMESPACE=${REDIS_CACHE_NAMESPACE:-quantdinger:cache:v1} - CACHE_ENABLED=true + - QUANTDINGER_DATE_DIR=${QUANTDINGER_DATE_DIR:-/app/DATE} + - QUANTDINGER_DATE_READ=${QUANTDINGER_DATE_READ:-true} + - QUANTDINGER_DATE_WRITE=${QUANTDINGER_DATE_WRITE:-true} + - CN_STOCK_PROVIDER=${CN_STOCK_PROVIDER:-rqdata} + - HK_STOCK_PROVIDER=${HK_STOCK_PROVIDER:-rqdata} + - FUTURES_CN_PROVIDER=${FUTURES_CN_PROVIDER:-rqdata} - QD_PROCESS_ROLE=api - LOG_FORMAT=${LOG_FORMAT:-json} - PROMETHEUS_MULTIPROC_DIR=/tmp/quantdinger-prometheus @@ -314,6 +329,7 @@ services: volumes: - backend_logs:/app/logs - backend_data:/app/data + - ./DATE:/app/DATE - ./backend_api_python/.env:/app/.env environment: <<: *worker-environment @@ -347,6 +363,7 @@ services: volumes: - backend_logs:/app/logs - backend_data:/app/data + - ./DATE:/app/DATE - ./backend_api_python/.env:/app/.env environment: <<: *worker-environment @@ -379,6 +396,7 @@ services: volumes: - backend_logs:/app/logs - backend_data:/app/data + - ./DATE:/app/DATE - ./backend_api_python/.env:/app/.env environment: <<: *worker-environment @@ -409,6 +427,7 @@ services: volumes: - backend_logs:/app/logs - backend_data:/app/data + - ./DATE:/app/DATE - ./backend_api_python/.env:/app/.env environment: <<: *worker-environment