
Rq Morning Note
- 1 installs
- 43 repo stars
- Updated June 23, 2026
- ricequant/ricequant-skills
Generate a standardized pre-market morning note from RiceQuant JSON feeds for a defined equity coverage pool.
About
rq-morning-note is an agent skill for quant and trading operators who need a repeatable 晨会纪要 (morning meeting note) without hand-assembling headlines from scattered files. It drives a report from `morning-note/scripts/generate_report.py`, reading JSON under `--data-dir` and filling templated slots such as report date, as-of time, lookback window, and coverage scope. The README defines strict consumption rules—for example, older earnings must not be labeled as overnight updates, and price-based commentary needs two observations per symbol. That discipline keeps agent-generated prose aligned with auditable inputs, which matters when you are the only analyst on the desk. Use it when you already maintain RiceQuant-style pools and nightly extracts and want Claude Code or Cursor to draft the note structure while you validate numbers. It is a structured reporting workflow, not a live market data connector.
- Chinese morning-meeting template with fixed sections: executive summary, overnight moves, market recap, watchlist, trade
- Documented data contract for seven JSON inputs: stock_pool, instrument_meta, latest_earnings, price_recent, hs300_recent
- Cross-checks coverage scope against instrument metadata and company names
- Restricts earnings narrative to disclosures inside the overnight lookback window
- Requires at least two trading days of prices to compute moves and HS300-relative strength
Rq Morning Note by the numbers
- 1 all-time installs (skills.sh)
- Ranked #909 of 1,106 Finance & Trading skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Aug 3, 2026 (Skillselion catalog sync)
npx skills add https://github.com/ricequant/ricequant-skills --skill rq-morning-noteAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 43 |
| Security audit | 2 / 3 scanners passed |
| Last updated | June 23, 2026 |
| Repository | ricequant/ricequant-skills ↗ |
What it does
Generate a standardized pre-market morning note from RiceQuant JSON feeds for a defined equity coverage pool.
Files
RQ 股票研究 - 晨会纪要
核心原则
- 所有内容必须遵循三阶段流程:数据采集 -> 报告生成 -> HTML 渲染
assets/template.md是唯一报告模板来源;Python 只做数据归一化、占位符填充和结构校验- 晨会纪要必须先完整收集数据,再形成盘前判断,不能边抓数据边写结论
- 不能写死股票池、日期、板块、公司名称、交易结论
- 不能伪造具体盘中时间点;如果数据里没有明确时间,只能写“今日重点关注”
- 缺少数据时必须明确写“无数据/无事件/未验证”,不能留空章节
数据源分工
RQData CLI 负责
- 覆盖股票池、公司名称与基础元数据
- 个股价格、成交额、基准指数表现
- 公司公告、财报披露、分红事项
web_search 负责
- 隔夜宏观政策、海外市场、监管动态
- 行业新闻、商品与主题链条的实时语境
- RQData 无法直接提供的盘前网络搜索结果
web_search 禁止替代的内容
- 个股价格、基准指数、公告、财报、分红
- 股票池定义、相对强弱排序
- 任何本应由 RQData 提供的结构化金融主数据
web_search 使用规则
详细字段、来源等级、落盘示例和 fallback 规则见 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指定,不能写死固定路径
目录结构
morning-note/
├── SKILL.md
├── scripts/
│ └── generate_report.py
├── assets/
│ └── template.md
└── references/
├── data_contract.md
└── web_search.md输入文件契约
原始数据目录由 --data-dir 指定,脚本会按下列文件名查找输入:
stock_pool.jsoninstrument_meta.jsonlatest_earnings.jsonprice_recent.jsonhs300_recent.jsondividend_news.jsonannouncement_raw.jsonweb_search_findings.json(可选)
完整字段说明见 references/data_contract.md。
工作流
步骤 1:准备参数
REPORT_DATE="${REPORT_DATE:-$(date +%F)}"
LOOKBACK_START="$(python3 - <<PY
from datetime import date, timedelta
report_date = date.fromisoformat("${REPORT_DATE}")
print((report_date - timedelta(days=1)).isoformat())
PY
)"
PRICE_LOOKBACK_START="$(python3 - <<PY
from datetime import date, timedelta
report_date = date.fromisoformat("${REPORT_DATE}")
print((report_date - timedelta(days=7)).isoformat())
PY
)"
DIVIDEND_LOOKBACK_START="$(python3 - <<PY
from datetime import date, timedelta
report_date = date.fromisoformat("${REPORT_DATE}")
print((report_date - timedelta(days=30)).isoformat())
PY
)"
FINANCIAL_START_QUARTER="$(python3 - <<PY
from datetime import date
report_date = date.fromisoformat("${REPORT_DATE}")
print(f"{report_date.year - 1}q1")
PY
)"
FINANCIAL_END_QUARTER="$(python3 - <<PY
from datetime import date
report_date = date.fromisoformat("${REPORT_DATE}")
print(f"{report_date.year}q4")
PY
)"
ORDER_BOOK_IDS_JSON="${ORDER_BOOK_IDS_JSON:-[\"600519.XSHG\",\"000858.XSHE\",\"300750.XSHE\",\"600000.XSHG\"]}"
DATA_DIR="${DATA_DIR:-$HOME/rq_equities_reports/morning_note}"
OUTPUT_MD="${OUTPUT_MD:-$DATA_DIR/morning_note_${REPORT_DATE}.md}"步骤 2:采集结构化主数据
mkdir -p "$DATA_DIR"
printf '{"data": %s}\n' "$ORDER_BOOK_IDS_JSON" > "$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 报告
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 生成
- 占位符采用
[[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 字段名或内部执行语言
- 数据不足时输出空标题或模板示例文本
晨会纪要
报告日期:[[REPORT_DATE]] 信息截面:[[AS_OF_TIME]] 隔夜观察区间:[[LOOKBACK_START]] 至 [[REPORT_DATE]] 覆盖范围:[[COVERAGE_SCOPE]]
执行摘要
[[EXEC_SUMMARY]]
隔夜动态
[[OVERNIGHT_DEVELOPMENTS]]
昨日市场回顾
[[MARKET_RECAP]]
今日重点关注
[[WATCHLIST]]
交易观察
[[TRADE_OBSERVATIONS]]
风险提示
[[RISK_ALERTS]]
附录:口径说明
[[APPENDIX]]
morning-note 数据契约
morning-note/scripts/generate_report.py 默认从 --data-dir 读取以下 JSON 文件。
1. stock_pool.json
允许格式:
{
"data": ["600519.XSHG", "300750.XSHE"]
}用途:
- 明确盘前覆盖股票池
- 与元数据文件交叉校验公司名称
2. instrument_meta.json
典型字段:
order_book_idsymbolabbrev_symbollisted_datesector_code_name
用途:
- 映射股票代码与公司简称
- 生成覆盖范围文字
3. latest_earnings.json
典型字段:
order_book_idquarter或report_periodinfo_date或report_daterevenuenet_profit
用途:
- 识别隔夜窗口内的财报披露
- 为执行摘要和重点跟踪项提供财务事实
补充说明:
- 晨会纪要只消费隔夜窗口内已披露的记录,不能把更早的财务数据写成“隔夜更新”
4. price_recent.json
典型字段:
order_book_iddatetimeclosetotal_turnover
用途:
- 回顾昨日股价表现
- 识别相对强弱个股
补充说明:
- 至少需要两个交易日观察值才能计算涨跌幅
5. hs300_recent.json
典型字段:
order_book_iddatetimeclose
用途:
- 计算沪深300基准涨跌幅
- 给覆盖池相对强弱提供参考基线
6. dividend_news.json
典型字段:
order_book_idannouncement_dateex_dividend_datebook_closure_datepayable_datedividend_cash_before_taxcash_dividend_per_share
用途:
- 识别新披露分红信息
- 标记临近除权除息事项
7. announcement_raw.json
典型字段:
order_book_idtitle/announcement_title/info_nameannouncement_date/ann_date/pub_date/info_dateannouncement_linkinfo_typemedia
用途:
- 识别隔夜重点公告
- 为盘前关注名单保留原始追溯链接
补充说明:
- 若存在
announcement_link,高优先级事项应在正文中保留客户可点击链接 - 客户稿可以保留原文链接,但不能暴露内部字段名
8. web_search_findings.json
该文件可选,仅用于补充宏观、政策、海外市场、行业新闻和监管动态。
每条记录至少包含:
querysource_namesource_typetitleurlpublished_atretrieved_atsummarywhy_relevantconfidencefinding_type
推荐附加字段:
subjectrelated_entities
用途:
- 补充 RQData 无法直接提供的盘前宏观和行业语境
- 支持执行摘要里的“盘前定位”与“今日重点关注”
限制:
web_search_findings.json不能替代个股行情、公告、财报和分红等结构化主数据- 若未提供该文件,晨会仍可交付,但需保持“结构化盘前纪要”边界,不能伪造实时新闻
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_searchto 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:
querysource_namesource_typetitleurlpublished_atretrieved_atsummarywhy_relevantconfidencefinding_type
Recommended fields:
subjectrelated_entities
Allowed finding_type
macro_contextpolicy_contextindustry_contextglobal_market_contextcommodity_context
Source Types And Confidence Ceiling
official: max confidence5government: max confidence4association: max confidence4authoritative_media: max confidence4general_news: max confidence3inference: max confidence1
Search Workflow
1. Confirm the needed information is not directly available from RQData CLI. 2. Prefer official and primary sources first. 3. Save the finding into web_search_findings.json with structured metadata. 4. Keep the summary factual and keep the relevance note 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
{
"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": "流动性操作"
}
]
}#!/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()
Related skills
FAQ
Is Rq Morning Note safe to install?
skills.sh reports 2 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.