先说点实际的:这个项目我前后改了三个版本,从最初“让ChatGPT直接给我权重”的莽撞方案,到最终“大模型负责选股和定性分析,量化优化器负责权重计算”的稳定架构,中间踩了不少坑。如果你也想把大模型和投资组合优化结合起来,这套系统从数据、提示词、Agent编排到夏普比率优化的完整实现,应该能帮你省掉几个星期的试错时间。
这套系统不是什么黑魔法,本质就是两段式流程:先让大模型对股票池里的标的做基本面、新闻、行情形态的综合分析,输出一份“值得关注”的候选名单;再用夏普比率作为目标函数,对候选名单做组合权重优化,最终得到一篮子配置比例。全文会围绕这个思路展开,涉及数据怎么取、提示词怎么写才不会让模型胡说、夏普比率计算口径有哪些坑、优化器参数怎么设,最后附上我实际排查过的常见问题。
先给结论:这类系统的天花板不取决于模型智商,取决于数据质量和流程约束。LLM擅长做“阅读理解”和“风险发现”,不擅长做“精确计算”和“纪律执行”,所以正确的架构是把模型当研究员,把优化器当基金经理,Agent只负责把两段流程串起来。冲着“让AI自动炒股”来的朋友可以省省了,看完这篇你会明白边界在哪里。
1. 系统要解决什么问题,为什么这么设计
1.1 大模型荐股,本质是把“分析师”自动化
散户最常见的操作是“凭感觉选股”:看到某只股票涨得好,或者朋友推荐了一句,就拍脑袋买入。这套系统的第一个目标,就是把“收集信息—阅读财报—识别风险—形成观点”这个过程变成可复现的流程。
这里强调一个核心认知:大模型荐股和你自己看股票没有本质区别,它也是基于已知信息做推断,只是速度快、覆盖广、不会被情绪干扰。实测下来,GPT级别的模型读一份财报、提取关键风险点、对比同行业公司,效率远超人工,但它的判断质量上限取决于你喂给它的数据质量。所以我花了大量时间在数据模块上,后面会细说。
系统选用的技术路线是:行情数据用AKShare获取,基本面数据用财报数据加新闻摘要,大模型用ChatGPT API(也可以替换成任意本地部署模型),组合优化用PyPortfolioOpt库。全部用Python实现,模块化拆分,每一步都可独立运行、独立验证。
1.2 为什么组合优化选夏普比率而不是单纯收益率
如果你只让模型挑“最有可能涨”的几只股票,会遇到一个经典问题:涨幅榜前列永远是高风险标的,回撤起来也最凶。单纯的预期收益指标无法区分“靠运气赚的钱”和“靠风险补偿赚的钱”。
夏普比率衡量的是一段时期内,每承担一单位风险能换来多少超额收益,公式是:
夏普比率 = (策略收益率 - 无风险利率) / 策略波动率它把收益和风险放在同一个标准下比较。实际上我更常用年化口径,也就是把日频数据换算成年化值:
年化夏普 = (日均收益 - 无风险利率/252) / 日收益标准差 * sqrt(252)选择夏普而不是单纯收益率的决策逻辑在于:我关心的是“这个组合能不能睡得着觉”。一个年化收益20%但最大回撤40%的组合,和一个年化收益15%但最大回撤10%的组合,后者在实际持有体验上远好于前者,而夏普比率正好能够量化这层关系。
1.3 Agent如何把“推荐”变成“可执行策略”
刚开始做的时候,我企图让大模型一口气输出“买入股票A、B、C,仓位分别是多少”,结果模型给的权重极不稳定,同一个提问换一天问,答案就完全不同,而且经常出现权重加起来不等于1的情况。这是一个非常典型的问题:大模型本质是文本生成模型,数学约束天然是弱项。
后来我把重活分出去了,引入Agent架构做流程编排。系统里定义了两个角色:
| Agent | 职责 | 输入 | 输出 |
|---|---|---|---|
| Analyst Agent | 分析个股,识别风险,生成结构化观点 | 行情数据、财务指标、新闻摘要 | JSON格式的候选列表与评分 |
| Optimizer Agent | 执行组合优化计算 | Analyst Agent输出的候选列表、历史收益率数据 | 最优权重组合与绩效指标 |
两个Agent之间不是简单的对话关系,而是通过结构化数据传递。Analyst Agent完成分析后把结果写入一个JSON文件,Optimizer Agent读取这个文件后执行计算。这个设计带来的好处非常明显:任何一步失败都可以定位到具体模块,不会出现“模型聊着聊着跑题了导致整个流程崩溃”的尴尬局面。
2. 数据层:大模型荐股的第一步是数据干净
2.1 行情数据获取与复权处理
推荐股票的前提是能拿到准确的行情数据。我用的数据源是AKShare,接口免费、字段比较全,日常实验足够用了。以A股为例,获取某只股票日K线数据的代码非常简单:
import akshare as ak # 获取贵州茅台日线数据,period="daily" 表示日线,adjust="qfq" 表示前复权 df = ak.stock_zh_a_hist(symbol="600519", period="daily", adjust="qfq") print(df.tail())这里有一个非常关键的技术细节:前复权和后复权的选择。前复权以当前价格为基准,将历史价格做调整,保证股价连续;后复权则保留历史真实价格,调整当前价格。做回测和收益率计算时建议统一使用前复权,因为它的价格序列更贴近模拟交易中的真实体验。
我在实际项目中遇到的第一个坑就出在这里:某股票发生过10转10的高送转,除权日后股价直接腰斩,如果不做复权处理,模型看到的收益率曲线会出现一个巨大的虚假跳水,提示词再精准也无济于事。所以数据层的复权处理是基本功,宁可慢一点,也不能用未复权数据直接喂给模型。
2.2 基本面数据与新闻数据准备
大模型荐股的信息面不能只靠K线,否则跟技术指标派没有区别,而且LLM的K线形态识别能力其实一般。我的做法是给模型同时提供三类数据:
- 财务指标:营收增速、净利润增速、ROE、毛利率、资产负债率、现金流。
- 估值指标:PE、PB、股息率,用于识别“贵不贵”。
- 新闻摘要:抓取最近30天的相关新闻标题,让模型做情绪面判断。
财务指标通过AKShare的stock_financial_abstract接口可以拉到,新闻数据则可以从财经网站的RSS或搜索接口聚合。这里强调一个经验:不要直接给模型十几页财报原文,它有上下文限制,而且注意力会被无关信息稀释。正确做法是先用规则做一遍提纯,比如只保留“净利润同比变化大于20%”的条目,再交给LLM做二轮分析。
我自己写了一个类函数实现数据准备:
def prepare_analysis_input(stock_code): price_df = fetch_price_data(stock_code) fin_df = fetch_financial_data(stock_code) news_list = fetch_news(stock_code, days=30) return { "code": stock_code, "recent_financials": summarize_financials(fin_df), "news_headlines": [n["title"] for n in news_list], "price_trend": compute_quick_indicators(price_df), "risk_flag": check_risk_items(fin_df) # 自定义规则,如商誉过高、现金流为负等 }2.3 数据对齐中的三个典型坑
数据模块看起来简单,实际操作中坑不少。这里把最典型的三个列出来供参考:
第一,股票代码的交易所前缀问题。上证股票是600、601、603开头,深证是000、002、300开头,北交所有自己的专属编码。AKShare接口里A股代码不区分前缀,但做数据合并时如果混入了指数代码或基金代码,会导致后面协方差矩阵计算直接报维度错误。建议统一维护一份代码表,在数据入口处做白名单校验。
第二,交易日期不一致。不同数据源返回的日期范围可能不同,某只股票停牌期间没有行情记录,直接用pct_change()计算收益率时,停牌日会出现NaN。这里的经验是构建一个统一交易日历,用reindex重索引后再做收益率计算,缺数据的位置填充0或直接dropna,二选一后必须全局一致。
第三,幸存者偏差。如果只取当前还在交易的股票列表做回测,会默认排除掉已经退市的股票,回测结果虚高。解决方案是:取股票池时使用“期初存在”的标的,而不是“期末存在”的标的。这个坑在机构级系统里是常识,但个人项目里很多人第一次回测都逃不掉,包括我,切记。
3. 大模型荐股:提示词、输出约束与幻觉治理
3.1 提示词设计:不是让它写作文,是让它填表
大模型荐股的提示词,我前前后后迭代了十多个版本。最大的感悟是:大多数人写提示词都在“邀请模型自由发挥”,而工程化的提示词应该“限制模型自由发挥”。
早期版本我是这么写的:
你是一个股票分析师,请分析以下股票的走势并给出建议。然后模型会输出一大段文字,语言很华丽,但没有结构化字段,后续程序完全没法解析。后来改成:
你是股票分析助手。请基于以下数据,对股票给出结构化分析结论。要求: 1. 输出格式必须为JSON,不要输出任何其他文字。 2. JSON结构固定为 {"score": 总分(0-100), "strengths": [优势点], "risks": [风险点], "comment": 总结}。 3. score只考虑当前数据,不要预测未来点位。 4. 如果有数据缺失,risks中必须包含"数据缺失"字段。实测下来,明确限制输出格式后,模型分析的稳定性大幅提升,解析成功率从70%左右直接拉到95%以上。还有一点值得注意:我要求模型把“数据缺失”显式写进风险字段,这是为了做下游过滤,如果一个标的关键财务数据缺失,系统会直接淘汰它,而不是让模型强行脑补。
3.2 结构化输出:JSON和Schema校验
只要求JSON还不够,生成的JSON偶尔还是会有字段不对的情况,比如score超出了0-100的范围,或者strengths写成了字符串。我额外加了一层Schema校验,用Pydantic定义输出结构:
from pydantic import BaseModel, Field class StockAnalysis(BaseModel): score: float = Field(ge=0, le=100, description="总分0-100") strengths: list[str] risks: list[str] comment: str # 解析模型输出,失败则重试一次 import json raw_output = llm_call(prepare_analysis_input("600519")) try: data = StockAnalysis(**json.loads(raw_output)) except Exception: # 兜底:正则提取JSON块再解析 data = parse_json_with_regex(raw_output)这一步是深坑的避雷针。如果模型输出的JSON里score为120,直接放过会在下游优化器里引入脏数据,导致权重计算结果失真。校验失败时我采用“重试一次+使用上一次成功结果”的双保险策略,避免因为某次解析失败中断整个Agent流程。
3.3 幻觉治理:数值校验、结论投票
大模型荐股最大的争议点在于幻觉:模型可能一本正经地编造一个不存在的财务指标。治理手段我用了三层:
第一层:禁止复杂计算。提示词里明确告诉模型“你不擅长计算,请直接引用数据中给出的指标值,不要自行推算”。因为模型内部没有可靠的算术引擎,让它算PE同比变化或者毛利率环比,出错概率极高。
第二层:关键数值校验。模型输出的任何涉及具体数值的结论,都回扣到原始数据做规则校验。比如模型说“ROE超过20%”,程序就去财务数据里查证,查不到或对不上就丢弃该结论。这个校验规则写起来不难,但能挡住绝大多数幻觉。
第三层:多次采样投票。对同一只股票,用不同温度的Prompt跑三次,取中位数评分,同时要求模型在每次分析中给出一个“多空分歧”字段。如果三次结果分差超过15分,系统会标记该股票为“高分歧”,不给它进入组合优化的资格。这相当于给模型套了一层防抖机制,也减少了单次采样的随机性影响。
4. 夏普比率组合优化:从个股列表到最优权重
4.1 夏普比率的计算口径
组合优化环节中,夏普比率是目标函数。这里有一个不能忽略的细节:夏普比率并不是一个“唯一标准答案”,不同计算口径会得出不同结果。
最常用的是历史夏普比率,直接用历史收益率的均值和标准差计算。我在系统里采用这样的流程:
import pandas as pd # price_df 是多只股票的复权收盘价矩阵,index为日期,columns为股票代码 returns = price_df.pct_change().dropna() # 计算每只股票的日收益率均值与标准差 mu = returns.mean() std = returns.std() # 年化夏普 risk_free_rate = 0.02 sharpe = (mu - risk_free_rate / 252) / std * (252 ** 0.5)无风险利率我取2%,对应十年期国债收益率的近似水平。这个参数对最终权重有影响,但没有想象中那么大,因为它对每只股票的相对风险惩罚是一致的。真正影响大的,是计算历史收益率的窗口长度——窗口太长会包含太多旧信息,太短又会导致噪声过大。我实测下来,用过去120个交易日(约半年)的窗口效果比较适中,既覆盖了一个完整的财报周期,又不会因过久的数据拉低当前趋势的权重。
4.2 用PyPortfolioOpt做最大夏普组合
PyPortfolioOpt是一个开源组合优化库,底层封装了SciPy的优化器,专门干马克维茨均值-方差模型这件事。核心流程三步:估计预期收益、估计协方差矩阵、求解最优权重。
实测代码非常简洁:
from pypfopt import EfficientFrontier, risk_models, expected_returns # 计算历史预期收益与样本协方差 mu = expected_returns.mean_historical_return(price_df) cov = risk_models.sample_cov(price_df) # 求解最大夏普比率组合 ef = EfficientFrontier(mu, cov) weights = ef.max_sharpe(risk_free_rate=0.02) # 保留两位小数,展示权重 cleaned_weights = ef.clean_weights() print(cleaned_weights) # 输出组合预期绩效 performance = ef.portfolio_performance(verbose=True)这里要提醒一点:样本协方差矩阵在股票数量多于时间窗口长度时会变得不稳定,甚至出现不可逆的情况。如果股票池选了30只,历史数据窗口只有60天,协方差矩阵的估计误差会非常大,优化结果基本不可信。我的经验是候选池控制在10到20只之间,时间窗口120天以上,协方差估计才有可用的稳定性。如果必须处理大量标的,考虑用Ledoit-Wolf收缩估计代替样本协方差,PyPortfolioOpt里对应的是risk_models.CovarianceShrinkage。
4.3 约束条件与过拟合的博弈
直接跑max_sharpe(),结果往往很极端:仓位高度集中到一两只股票上,因为历史回测中它们恰好有最好的风险调整后收益。这在样本内很漂亮,样本外大概率翻车。
我在优化器里加了几条硬约束,用来抑制过拟合:
- 单只股票最大权重上限:通常设为15%到20%。
- 单一行业最大权重上限:比如30%,避免同一行业的标的一荣俱荣、一损俱损。
- 最小权重过滤:优化后权重低于2%的标的直接归零,避免组合碎片化。
- 强制投资分散数量:最终组合持仓不低于8只股票。
在PyPortfolioOpt中设置方式如下:
ef.add_constraint(lambda w: w <= 0.15) # 单只股票不超15% ef.add_constraint(lambda w: w >= 0.02) # 低于2%的权重不参与 # 行业约束如果数据里有行业字段,可以用for循环对每个行业加约束 ef.max_sharpe(risk_free_rate=0.02)调参过程中我自己的心得是:权重上限不要设得太紧,15%在10只股票的池子里意味着天然需要分散,20%更适合认知度更高的标的。这个没有标准答案,关键是想清楚你到底是在优化历史曲线,还是在构建一个能扛住未来说明变化的组合。给历史拟合留一点余地,给未来不确定性留更多缓冲,这是组合优化比单股预测更接近实战的原因。
5. 落地实操:从数据到回测的完整链路
5.1 环境准备与目录结构
整个项目我维护了一套非常清晰的目录结构,方便复盘和调试:
project/ ├── agent/ # Agent编排 │ ├── analyst_agent.py │ └── optimizer_agent.py ├── data/ # 数据模块 │ ├── fetch_data.py │ ├── clean_data.py │ └── calendar.py ├── prompts/ # 提示词模板 │ ├── analyst_prompt.py │ └── validator.py ├── optimizer/ # 组合优化 │ ├── max_sharpe.py │ └── constraints.py ├── backtest/ # 回测模块 │ ├── run_backtest.py │ └── metrics.py ├── docs/ # 教程文档 │ ├── 数据获取.md │ ├── 提示词调优.md │ └── 常见问题.md └── main.py # 主流程入口依赖库相对固定:akshare、pandas、numpy、pypfopt、openai(或本地推理引擎的SDK)、pydantic。建议用虚拟环境管理依赖,Python 3.10以上版本。
5.2 核心模块实现与运行结果
主流程的编排我用一个简单的状态机实现,没有引入重型Agent框架。核心思想是:任务队列顺序执行,每步产物落盘,失败可重试。
def run_pipeline(stock_pool): # 第一步:数据准备 raw_data = fetch_all(stock_pool) # 第二步:Analyst Agent 分析,得到候选列表 candidates = [] for stock in raw_data: analysis = analyst_agent.analyze(stock) if analysis.score >= 60 and "数据缺失" not in analysis.risks: candidates.append(stock) # 第三步:Optimizer Agent 计算最优权重 price_matrix = build_price_matrix(candidates) optimal = optimizer_agent.maximize_sharpe(price_matrix) # 第四步:回测验证 metrics = backtest(optimal) return {"candidates": candidates, "weights": optimal, "metrics": metrics}一次完整运行的实际输出大致长这样:
候选标的有 14 只,其中 4 只因风险字段触发剔除 优化器求解成功,最优组合年化夏普比率为 1.35 组合年化预期收益 18.2%,年化波动率 13.5%,最大回撤 -9.8% 持仓数量 10 只,最大单票权重 14.8%当然这个指标有标的市场环境加成,不必太当真,关键是流程能通、能复现。后面我在文档里保留了每次运行的配置文件和结果快照,方便对比参数改动前后的差异。
5.3 回测结果怎么解读才不会自嗨
回测的本质是对“历史状态”做模拟,但它永远无法证明未来会重复。我在解读回测时有几个硬指标必须同时看:
- 年化收益:代表赚钱能力,但单独看没意义。
- 最大回撤:比收益更影响真实持有体验,建议控制在20%以内。
- 夏普比率:判断风险调整后收益,达到1以上勉强能用,1.5以上算不错。
- 换手率:如果权重每月大幅调整,交易成本会吃掉收益,需要注意。
- 样本外表现:最容易被忽略的一点。我建议用“滚动前推”方式做验证,也就是拿前120天数据优化权重,下一天按这个权重模拟收益,逐步前移,而不是一次性用全量数据优化再回测——否则前视偏差会让结果虚高得不像话。
还有一个小经验:对回测曲线不要过度解读。一只标的在回测区间内贡献了主要收益,不代表未来几个月还靠它吃饭。组合优化的价值在于分散风险,不在于精准预测哪只股票涨得最多。用这个标准去看每一个跑出来的结果,你就不容易自我感动。
6. 常见问题与排查实录
6.1 LLM输出解析失败
这是最高频的问题,没有之一。即使提示词写得再严格,模型仍然可能偶尔输出Markdown格式的JSON、多余的解释文字或截断内容。
排查建议分三步:第一,检查raw输出里是否有代码块标记,比如开头的```json,用正则剥离;第二,检查JSON里是否有非法转义字符,常见于模型在字符串里直接嵌套了双引号;第三,设置重试机制,同一次分析最多重试两次,超过则将该标的标记为“解析失败”并跳过,而不是让整个流程卡死。
6.2 优化器求解失败或权重极端集中
PyPortfolioOpt的求解器偶尔会直接报错,某个协方差矩阵是奇异矩阵、不可逆,或者约束条件之间互相矛盾。出现这种情况时,先检查股票池里是否存在两只相关性极高的股票(比如同一行业的两只龙头),它们的存在会导致协方差矩阵接近奇异。解决办法是剔除其中一只,或改用Ledoit-Wolf收缩估计。
如果求解成功但权重极端集中,比如某只股票直接顶到40%权重,检查约束条件是否真的传入成功。我在调试时吃过一个哑巴亏:add_constraint传入的lambda写错了变量名,结果约束没生效,权重完全放飞,后来打印每种标的的约束上下界才定位到问题。
6.3 夏普比率算不对
算出来的夏普比率与其他平台的统计对不上,几乎都是口径问题。首先是频率问题:日频数据算出来的夏普必须乘以根号252做年化,如果乘以根号250或忘了开根号,结果天差地别。复查公式:
年化夏普 = 日频夏普 * sqrt(252)其次是无风险利率处理,有些平台用年化无风险利率直接除以252近似日频利率,有些直接用0。我的建议是统一使用年化2%并转换成日频,在文档里写清楚假设,不要混用。最后一个坑是收益率序列里混入了NaN,pandas默认跳过NaN会低估标准差,导致夏普虚高。处理方式是dropna后再统一计算,不要跳过。
6.4 Agent流程偶尔跑飞的处理
Agent流程跑飞通常表现为:某个环节进入死循环、某次API调用超时、或者中间产物文件损坏。我的处理方式比较务实:全程增加超时控制和状态检查,每个Agent执行完必须写一个状态文件,记录成功、失败或超时。主流程的编排层读到状态文件里的“失败”标记就自动进入重试分支,重试两次仍失败就跳过该标的并写日志。
日志分析很重要。我做了两级日志:一级记录流程关键节点,二级记录每个API调用的输入输出摘要。出了bug先看一级日志定位到模块,再看二级日志复现问题。这套机制帮我解决了至少十次“莫名奇妙跑出离谱结果”的情况,强烈建议你也加上。
结尾:一点个人体会
这个项目做完之后,我对“AI荐股”这件事的看法变得非常具体:它不是一个能替你赚钱的“股神”,而是一个帮你把决策流程变得更严格的工具。大模型负责把海量信息和风险点整理到你面前,优化器负责在收益和风险之间做一个数学上可解释的权衡,Agent把这套流程串成每天可以重复跑的自动化管道。
我在实盘里只会把它当成一个研究方向,不会盲目跟单,尤其是当某个组合的回测夏普高得离谱时,我会本能地怀疑是不是哪里漏了前视偏差。所有历史回测的漂亮曲线,都只代表“如果过去这样操作会怎样”,不代表未来。最实用的态度是:把系统输出当成一个需要你二次判断的输入,而不是可以直接执行的命令。
最后分享一个扩展思路:这套架构的大模型部分可以无缝替换成其他开源模型或本地小模型,只要保证输出JSON的格式一致即可。夏普优化部分也可以换成风险平价、最大分散度等目标函数,代码结构不用大改。如果你已经跑通了这个流程,下一步试试换目标函数、换股票池、调无风险利率参数,你会对“参数敏感度”有非常直观的感受——这比单纯看回测曲线有用得多。