前两篇我们搭了一个多 Agent 投研助手的完整框架——- 第一期:基本面、量化、舆情三个 Agent 并行研究,风控门禁拦截,Writer 模板化报告
- 第二期:辩论交叉验证机制,让 Agent 之间互相质疑、辩护、裁决
框架跑起来之后,一个读者私下问了我一个问题:
"三个 Agent 各写各的结论,营收增长 12.5% 说正面,RSI 超卖说反弹,舆情偏正面说情绪好——看起来很和谐,但好像少了点什么?"
少了什么?
少了"外面的世界"。
营收增长 12.5%,如果同期消费行业整体增长 20%,这不叫增长,叫跑输同行。RSI 超卖反弹,但如果 10Y 国债收益率已经飙到 4%,资金面整体收紧,超卖可能继续超卖。舆情偏正面,但如果 PMI 已经跌破 49 进入收缩区间,行业景气度在下行,正面新闻可能只是滞后指标。
只看公司自身,不看宏观和行业,就像只看一支球队的技术统计,不看对手是谁、天气怎样、裁判尺度如何。
今天这篇,就是给系统加上第四个研究维度——MacroAgent(宏观/行业 Agent)。
一、为什么需要宏观视角?
做投研的人都知道一个常识:个股的绝对表现,远不如相对表现重要。
一家公司净利润增长 15%,看上去不错。但如果它所在的行业整体增长 30%,这家公司其实在掉队。反过来,行业整体下滑 10%,这家公司只跌了 2%,其实是抗跌龙头。
问题在于,现有的三个 Agent 都没法给出这种"相对判断":
- 基本面 Agent 只看公司自己的财报数据——营收、利润、ROE、现金流、杠杆率
- 量化 Agent 只看个股的价量数据——涨跌幅、波动率、最大回撤、RSI
- 舆情 Agent 只看个股相关的新闻——情绪打分、热度、重复报道
三个 Agent 各有所长,但有一个共同盲区:它们都不知道"公司外面的世界"发生了什么。
宏观视角要回答三个具体问题:
- 利率环境:10Y 国债收益率是 1.5% 还是 4%?这直接决定了资金成本和估值锚。低利率环境下,高 ROE 的公司享受估值溢价;高利率环境下,现金流好的公司更受青睐。
- 政策方向:货币政策是宽松还是收紧?M2 增速是在加速还是减速?这影响的是整个市场的资金面和风险偏好。
- 行业景气度:制造业 PMI 是在 50 以上扩张,还是 50 以下收缩?行业指数是在跑赢大盘还是跑输?这决定了个股是顺风还是逆风。
一句话:宏观视角不是要替代个股研究,而是给个股研究提供一个"参照系"。
二、MacroAgent 的分析框架
MacroAgent 和现有的三个 Agent 一样,遵循同一个设计原则:确定性计算归代码,综合解读归 LLM。
具体来说,MacroAgent 输出 4-5 个 Finding,覆盖四个维度:
| | |
|---|
| | < 2.0% = 低利率利好估值;> 3.5% = 高利率压制估值;中间 = 中性 |
| | easing(宽松)= positive;tightening(收紧)= negative |
| | ≥ 51 = 扩张积极;< 49 = 收缩消极;中间 = 中性 |
| | 超额 ≥ +3% = positive;≤ -3% = negative;中间 = neutral |
如果配置了 LLM API Key,MacroAgent 还会在上述规则分析之后,调用 LLM 做一次交叉综合解读——把利率、政策、行业景气度放在一起看,给出 2-3 句话的综合研判。
三、数据层扩展:给数据快照加上"宏观视角"
前三期里,数据快照 MarketDataSnapshot 装的是个股维度的数据:行情、财务指标、新闻、估值。现在要加两个新字段:
class MarketDataSnapshot(BaseModel): symbol: str meta: SnapshotMeta company: Dict[str, Any] = Field(default_factory=dict) quotes: List[QuoteBar] = Field(default_factory=list) fundamentals: Dict[str, Any] = Field(default_factory=dict) news: List[NewsItem] = Field(default_factory=list) valuation: Dict[str, Any] = Field(default_factory=dict)# 新增 ↓ macro: Dict[str, Any] = Field(default_factory=dict) # 宏观指标 industry: Dict[str, Any] = Field(default_factory=dict) # 行业指标
macro 字典装的是宏观环境指标:
{ ”treasury_10y”: 1.68,# 10Y 国债收益率 (%) ”lpr_1y”: 3.10,# 1 年期 LPR (%) ”lpr_5y”: 3.60,# 5 年期 LPR (%) ”pmi”: 50.2,# 制造业 PMI ”m2_yoy”: 7.0,# M2 同比增速 (%) ”monetary_stance”: ”easing”,# 货币政策方向(规则推断)}
industry 字典装的是行业对比数据:
{ ”industry_name”: ”白酒”, ”industry_index_change”: 5.8,# 行业指数近期涨跌幅 (%) ”benchmark_index_change”: 3.2,# 沪深 300 同期涨跌幅 (%) ”relative_strength”: 2.6,# 行业超额收益 (%) ”peers”: [”五粮液”, ”泸州老窖”, ”山西汾酒”, ”洋河股份”],}
用 Dict[str, Any] 而不是强类型的 Pydantic 模型,是因为宏观数据的口径在不同数据源之间差异较大(比如 PMI 有制造业 PMI、非制造业 PMI、财新 PMI),用字典更灵活,后续扩展字段不需要改模型。
在 AkShareProvider 里新增两个方法:_fetch_macro() 和 _fetch_industry()。
宏观数据采集分三步,每步独立 try/except,一步失败不影响其他:
def _fetch_macro(self, ak, as_of: date) -> Dict[str, Any]: out: Dict[str, Any] = {}# 1. 10Y 国债收益率 try: df = ak.bond_zh_us_rate(start_date=...) out[”treasury_10y”] = round(float(latest[”中国国债收益率10年”]), 2) except Exception: log.warning(”akshare 国债收益率获取失败”)# 2. 制造业 PMI try: df = ak.macro_china_pmi()# 列名可能变化,用关键词匹配 for col in df.columns: if ”制造业” in str(col) or ”pmi” in str(col).lower(): out[”pmi”] = round(float(latest[col]), 1) break except Exception: log.warning(”akshare PMI 获取失败”)# 3. M2 同比增速 try: df = ak.macro_china_money_supply() for col in df.columns: if ”M2” in str(col) and ”同比” in str(col): out[”m2_yoy”] = round(float(latest[col]), 1) break except Exception: log.warning(”akshare M2 获取失败”)# 4. 基于利率水平推断货币政策方向 treasury = out.get(”treasury_10y”) if treasury is not None: if treasury < 2.0: out[”monetary_stance”] = ”easing” elif treasury > 3.5: out[”monetary_stance”] = ”tightening” else: out[”monetary_stance”] = ”neutral” return out
这里有两个值得注意的设计细节:
第一,列名匹配用关键词而非硬编码。AKShare 返回的 DataFrame 列名经常变(比如 "净资产收益率(%)" 和 "加权净资产收益率"),PMI 和 M2 的列名也一样。用 "制造业" in str(col) 做模糊匹配,比硬编码列名更稳健。
第二,货币政策方向是规则推断的,不是 LLM 判断的。这遵循了"确定性计算归代码"的原则。基于 10Y 国债收益率的绝对水平来划分宽松/中性/收紧,是一个简单但有效的启发式规则。
行业数据的采集类似——先拿沪深 300 做基准,再尝试拉行业板块指数,算出相对超额收益。
四、MacroAgent 代码拆解
MacroAgent 的核心逻辑不复杂,但有几个设计要点值得展开。
先看整体结构:
class MacroAgent(Agent): name = ”macro” async def run(self, ctx: AgentContext) -> List[Finding]: macro = ctx.snapshot.macro industry = ctx.snapshot.industry findings: List[Finding] = []# 维度 1:利率环境 treasury = _get(macro, ”treasury_10y”) if treasury is not None: direction = ”positive” if treasury < 2.0 else (”negative” if treasury > 3.5 else ”neutral”) findings.append(Finding( agent=self.name, claim=f”10Y 国债收益率 {treasury:.2f}%,{利率环境描述}”, direction=direction, confidence=0.75 if demo else 0.7, evidence=[make_evidence(ctx.snapshot, ”国债收益率”, ...)], calculation=calc(”10Y国债收益率”, treasury, ”%”), assumptions=[”宏观数据来源于公开统计口径”], ))# 维度 2:政策方向# 维度 3:PMI 景气度# 维度 4:行业相对强度# 维度 5(可选):LLM 综合解读 if not findings:# 数据缺失兜底 findings.append(Finding( agent=self.name, claim=”未获取到足够的宏观/行业数据”, direction=”neutral”, confidence=0.2, ... )) return findings
每个维度都遵循同一个模式:取数据 → 判定方向 → 构造 Finding(含 Evidence + Calculation)→ 追加到列表。这和 FundamentalAgent、QuantAgent 的写法完全一致。
10Y 国债收益率的绝对水平(1.68% vs 3.8%)本身就包含了大量信息。低利率环境下,资金成本低,高成长性公司的估值溢价更高;高利率环境下,确定性强的现金流更受青睐。
当然,利率的变化趋势(是在降还是在升)也很重要。但在 MVP 阶段,我们先看绝对水平,后续可以加入利率曲线的斜率。这也是渐进式迭代的好处——先用最简单的规则跑通流程,再根据实际效果决定是否增加复杂度。
stance = macro.get(”monetary_stance”)m2_yoy = _get(macro, ”m2_yoy”)if stance or m2_yoy is not None: stance_label = STANCE_LABEL.get(stance, ”未知”) direction = ”positive” if stance == ”easing” else (”negative” if stance == ”tightening” else ”neutral”) claim = f”货币政策倾向{stance_label},M2 同比 {m2_yoy:.1f}%” findings.append(...)
monetary_stance 是在数据采集阶段由规则推断的(基于 10Y 国债收益率水平)。这里 MacroAgent 只是把它和 M2 增速组合起来,形成一个更完整的政策信号判断。
为什么不让 LLM 来判断政策方向?因为政策方向的判断需要基于连续的时间序列数据(LPR 的变化趋势、央行公开市场操作的净投放量、财政支出的同比增速等),而 LLM 每次调用都是无状态的,它看到的只是当前快照。用规则做推断,结果更可追溯、更可复现。
PMI 是最直观的宏观指标:50 以上是扩张,50 以下是收缩。MacroAgent 在此基础上做了更细的划分:
pmi = _get(macro, ”pmi”)if pmi is not None: if pmi >= 51: direction = ”positive”# 明确扩张 elif pmi < 49: direction = ”negative”# 明确收缩 else: direction = ”neutral”# 50 ± 1 是模糊区间
50 ± 1 的"模糊区间"设为中性,是因为 PMI 本身有统计误差,49.8 和 50.2 的差异在经济含义上并不显著。
这是 MacroAgent 最有价值的一个维度:
rel_strength = _get(industry, ”relative_strength”)if rel_strength is not None: if rel_strength >= 3.0: direction = ”positive”# 行业明显跑赢大盘 elif rel_strength <= -3.0: direction = ”negative”# 行业明显跑输大盘 else: direction = ”neutral”# 与大盘接近 add( f”{industry_name}行业相对沪深300超额收益 {rel_strength:+.1f}%”, direction, ... )
relative_strength = 行业指数涨跌幅 - 沪深 300 涨跌幅。这个简单的差值,回答了一个基本面 Agent 和量化 Agent 都无法回答的问题:这只股票的表现,是因为公司自身好/差,还是因为整个行业好/差?
如果基本面 Agent 说"营收增长 12.5%",而 MacroAgent 说"白酒行业指数超额收益 +2.6%",两者合在一起的含义就是:公司增长略高于行业平均,但幅度不大。这比单看 12.5% 的数字要有用得多。
if ctx.settings.llm_api_key and findings: llm_summary = await self._llm_synthesis(ctx, macro, industry, findings) if llm_summary: findings.append(Finding( agent=self.name, claim=llm_summary, direction=”neutral”, confidence=0.6, assumptions=[”LLM 综合解读仅供参考,不构成独立结论”], ))
LLM 综合解读是可选的。有 API Key 时,把前面四个维度的数据和分析结果一起发给 LLM,让它做 2-3 句话的交叉分析。没有 API Key 时,跳过这一步,报告照样生成。
注意这个 Finding 的 confidence 设为 0.6(低于规则分析的 0.7-0.75),并且 direction 固定为 "neutral"——因为 LLM 的解读是辅助信息,不应该主导报告的结论方向。
五、编排器和报告联动:改动的代码量超乎想象地少
这是整个改动里最"反直觉"的部分——接入一个新 Agent,编排器只改了一行代码。
agents = [FundamentalAgent(), QuantAgent(), SentimentAgent(), MacroAgent()]
就这一行。MacroAgent 被加到 Agent 列表之后,asyncio.gather 会自动把它和另外三个 Agent 一起并行执行。辩论环节也不用改——DebateAgent 遍历的是 Agent 列表,MacroAgent 自动参与质疑和辩护。
这就是面向接口编程的好处。每个 Agent 都实现了同一个 Agent 基类的 run() 方法,编排器不关心具体是哪个 Agent,只负责调度和汇总。加一个新 Agent,就像往列表里加一个元素。
required_agents=[”fundamental”, ”quant”, ”sentiment”, ”macro”, ”risk”, ”writer”],
Writer 的 SECTION_ORDER 加了一个 "macro":
AGENT_LABELS = { ”fundamental”: ”基本面”, ”quant”: ”技术面/量化”, ”sentiment”: ”舆情”, ”macro”: ”宏观/行业”, # 新增 ”risk”: ”风险审查”,}SECTION_ORDER = [”fundamental”, ”quant”, ”sentiment”, ”macro”, ”risk”]
Writer 遍历 SECTION_ORDER 渲染报告章节,加了 "macro" 之后,MacroAgent 的 Finding 会自动出现在"宏观/行业"章节里,格式和其他章节完全一致。
RiskAgent 新增了两条和宏观环境相关的风控门禁:
# 利率环境偏紧treasury = macro.get(”treasury_10y”)if treasury is not None and float(treasury) > 3.5: flags.append(”RATE_HIKE_RISK”)# 行业景气度下行:PMI < 49 且行业超额收益 ≤ -3%pmi = macro.get(”pmi”)rel_strength = industry.get(”relative_strength”)if pmi is not None and float(pmi) < 49 and rel_strength is not None and float(rel_strength) <= -3.0: flags.append(”INDUSTRY_DOWNTURN”)
这两条规则的设计逻辑是:
RATE_HIKE_RISK:10Y 国债收益率超过 3.5% 意味着高利率环境,对所有权益类资产都是压力。这是一个"全局性"的风险标记,不针对个股。INDUSTRY_DOWNTURN:PMI 收缩 + 行业跑输大盘,两个条件同时满足才触发。这是为了避免误报——PMI 短暂跌破 50 但行业指数仍然强势,可能只是短期扰动。
六、实际效果:报告多了什么?
用贵州茅台的 demo 数据跑一下:
python -m research_agent research 600519.SH --demo
报告中新增了一个宏观/行业章节:
## 宏观/行业- ✅ 10Y 国债收益率 1.68%,低利率环境利好估值(10Y国债收益率 = 1.68%),置信度 75%- ✅ 货币政策倾向宽松,M2 同比 7.0%(M2同比增速 = 7%),置信度 70%- • 制造业 PMI = 50.2,处于扩张区间(PMI = 50.2),置信度 70%- ✅ 白酒行业相对沪深300超额收益 +2.6%(行业指数-基准指数 = 2.6%),置信度 70%
四条结论,三个正面、一个中性。这和基本面 Agent 的结论(营收增长 12.5%、ROE 28.9%)形成了正向共振——宏观环境宽松、行业景气度扩张、行业跑赢大盘,基本面增长的"含金量"更高。
如果换成一个行业景气度下行的标的(比如 PMI 48.5、行业超额收益 -5%),报告里会出现:
## 宏观/行业- ✅ 10Y 国债收益率 1.68%,低利率环境利好估值,置信度 75%- ✅ 货币政策倾向宽松,M2 同比 7.0%,置信度 70%- ⚠️ 制造业 PMI = 48.5,处于收缩区间,置信度 70%- ⚠️ XX行业相对沪深300超额收益 -5.0%,置信度 70%
这时基本面 Agent 可能仍然说"营收增长 8%",但宏观 Agent 告诉你:行业整体在收缩,这只股票的营收增长可能是因为抢了同行的份额,而不是行业红利。这种交叉信息对投资决策的价值远大于单一维度。
开启辩论模式后(--debate),MacroAgent 会自动参与辩论:
- 基本面 Agent 可能质疑 MacroAgent:"PMI 是制造业整体指标,不能直接套用到白酒行业"——这是一个合理的质疑,因为白酒不是典型的制造业。
- MacroAgent 可能质疑量化 Agent:"RSI 超卖反弹的统计规律,在行业景气度下行时有效性降低"——这也是一个有实质依据的质疑。
辩论的结果取决于 LLM 的分析(或者 Mock 模式下的规则逻辑),但重要的是:这些质疑现在有机会被提出来了。在只有三个 Agent 的时候,没有人会去质疑"PMI 和白酒有什么关系",因为压根没有 PMI 这个数据。
七、这还只是第三步
三篇下来,系统的研究维度已经从三个扩展到了四个,加上辩论机制,一共有:
- 4 个研究 Agent:基本面、量化、舆情、宏观/行业
- 1 个 Writer:自动渲染五个章节的结构化报告
后续排队的改进:
- 估值 Agent:PE/PB/EV-EBITDA 分位数、DCF 估值区间、估值和宏观利率的联动关系
- 多轮收敛辩论:当前只辩一轮,后续可以设定 2-3 轮,让各方充分交锋后收敛
- DebateAgent 专业化:让每个 Agent 只质疑与自己专业相关的结论(比如 MacroAgent 只质疑"未考虑宏观环境"的结论)
- 定时任务与事件告警:PMI 公布日自动提醒、LPR 变动跟踪、行业指数异动监控
- 知识图谱:产业链上下游关系,让宏观分析能精确定位到"利率上升对哪些行业影响最大"
项目持续开源迭代,每个版本都会在公众号同步说明。
八、关注领取
完整源码和架构设计文档已更新,包含 MacroAgent 的全部实现、AKShare 宏观数据适配、以及风控规则的扩展。
点赞 + 关注,后台回复「AI投研」即可领取项目地址与完整源码。
觉得有用的话,顺手点赞 + 在看 + 转发,让更多做投研的朋友看到。
免责声明:本项目为技术研究与教育用途,输出内容不构成任何投资建议。市场有风险,决策需谨慎。