1. 从一条标题说起:这个系统到底在解决什么问题
第一次看到“ChatGPT大模型荐股与夏普比率投资组合优化系统”这个标题,我脑子里冒出来的第一个念头是:又是一个把大模型和量化硬凑在一起的项目。但仔细拆开看,它其实踩中了两个非常实在的痛点——一是普通人面对几千只标的根本不知道从哪儿下手,二是就算选出了几只,也不知道怎么分配仓位才能让收益和风险达到一个舒服的平衡。前者靠大模型的语义理解和信息整合能力来辅助筛选,后者靠夏普比率这个经典指标来做组合优化。两件事拼在一起,就是一个从“选什么”到“买多少”的完整链路。
我自己做量化策略断断续续有五六年了,从最早的纯规则因子选股,到后来尝试用机器学习做排序,再到最近一年把大模型引入到信息处理环节,踩过的坑可以说是一箩筐。这个项目的思路之所以值得拿出来讲,是因为它没有把大模型当成一个“预测涨跌的水晶球”,而是把它定位成一个“信息压缩和逻辑推理的助手”。这个定位非常关键,定位错了,后面全是白费功夫。
这篇文章适合几类人看:一是对量化投资有兴趣但不知道从哪儿开始搭系统的朋友;二是已经在用传统方法做选股和组合优化,想看看大模型能怎么融入进来的从业者;三是对Agent架构感兴趣,想找一个完整落地案例来学习的开发者。我会把整个系统的设计思路、核心模块的实现细节、实操过程中遇到的问题和解决办法都摊开来讲,尽量做到你照着做就能跑起来。
提示:本文涉及的所有代码和参数都是基于公开数据和常见工具链的实践总结,不构成任何投资建议。市场有风险,任何策略都需要经过充分的回测和风险评估。
2. 系统整体架构:为什么这样拆模块
2.1 三层架构的设计逻辑
这个系统我把它拆成了三层:数据层、Agent推理层、组合优化层。为什么是三层而不是两层或者四层?这是我在实际搭建过程中反复调整后确定的结构。
数据层负责的事情很纯粹——把原始的行情数据、财务数据、新闻文本抓取回来,做清洗和标准化。这一层不涉及任何推理逻辑,就是老老实实地做ETL。我见过很多人喜欢把数据清洗和特征计算混在一起,结果就是每次改一个特征就要动数据管道,维护起来非常痛苦。分开之后,数据层只管“把干净的数据准备好”,上层要什么格式我就输出什么格式。
Agent推理层是整个系统最有意思的部分。它接收数据层传来的结构化数据和非结构化文本,通过大模型的语义理解能力,输出一个初步的标的筛选结果和推荐理由。注意,这里输出的不是“买”或“卖”的信号,而是一个候选池加上结构化的推荐逻辑。为什么不让大模型直接给买卖信号?因为大模型在数值预测上的可靠性远远不如它在文本理解和逻辑归纳上的表现。让它做它擅长的事,这是用大模型做量化的第一原则。
组合优化层拿到候选池之后,用夏普比率作为目标函数,结合协方差矩阵和预期收益,求解最优权重。这一层是纯数学的,不涉及任何大模型调用。这样做的好处是,整个系统的可解释性很强——你可以清楚地知道哪些标的被选中是因为大模型的推理,哪些仓位被调高是因为数学优化的结果。
2.2 为什么选夏普比率作为优化目标
夏普比率的公式很简单:(组合预期收益 - 无风险利率) / 组合波动率。但它的含义很深刻——它衡量的是每承担一单位风险,你能获得多少超额收益。很多人做组合优化喜欢直接用收益最大化,结果就是仓位全压在波动最大的那几只标的上,回撤起来非常难受。夏普比率把风险放进了分母,天然地惩罚了高波动,这对于追求稳健复利的投资者来说更友好。
我在实际使用中发现,夏普比率优化出来的组合,在震荡市里的表现明显好于纯收益最大化的组合。当然它也有缺点——对预期收益的估计非常敏感,如果预期收益估偏了,优化出来的权重可能会很极端。所以我在实现的时候加了一个权重上限约束,单只标的的权重不超过20%,这样即使某只标的的预期收益估得特别高,也不会让整个组合过度集中。
2.3 Agent在整个系统中的角色定位
Agent在这个系统里不是“决策者”,而是“信息处理员”和“逻辑推理员”。具体来说,它做三件事:
第一,信息抽取。从新闻、公告、研报里提取出与标的相关的关键事件和情绪倾向。比如某公司发布了业绩预告,Agent需要判断这是超预期还是低于预期,以及市场可能怎么解读。
第二,多因子逻辑整合。把技术面指标(动量、波动率)、基本面指标(估值、盈利质量)和消息面信息综合起来,给出一个结构化的评分和理由。这一步的关键是让大模型输出可追溯的推理链,而不是一个黑箱分数。
第三,动态调整建议。当市场环境发生变化时(比如波动率突然放大),Agent可以根据预设的规则给出调整建议,比如“建议降低高波动标的的权重”。
注意:Agent的输出必须经过结构化校验。我试过直接让大模型输出JSON,结果它经常在JSON里加注释或者用单引号,导致解析失败。后来我用了function calling的方式,把输出格式严格约束住,才稳定下来。
3. 数据层搭建:从原始数据到可用特征
3.1 数据源选择与获取方式
数据是整个系统的地基,地基不牢后面全塌。我用的数据源分三类:
行情数据:日线级别的OHLCV数据,我用的是公开的金融数据接口,每天收盘后更新一次。如果你要做日内策略,那需要更细粒度的数据,但日线对于这个系统的定位来说足够了。
财务数据:季度更新的财务指标,包括营收、净利润、ROE、资产负债率等。这些数据我从公开的财务数据接口获取,注意要处理财报发布的时间差——很多财务指标在报告期结束后一两个月才公布,如果你直接用报告期的数据做回测,就会引入未来函数,回测结果会虚高。
文本数据:新闻标题和摘要、公司公告。这部分数据我用的是公开的新闻聚合接口,每天抓取一次。文本数据不需要全文,标题和摘要就够用了,因为大模型处理长文本的成本很高,而且核心信息通常在标题里就已经体现。
# 数据获取的简化示例 import pandas as pd import requests def fetch_daily_price(symbol, start_date, end_date): """获取日线行情数据""" # 这里用伪代码表示,实际替换为你的数据源接口 url = f"https://api.example.com/price/{symbol}" params = {"start": start_date, "end": end_date} resp = requests.get(url, params=params) df = pd.DataFrame(resp.json()["data"]) df["date"] = pd.to_datetime(df["date"]) df = df.sort_values("date").reset_index(drop=True) return df def fetch_financial(symbol, quarter): """获取财务数据,注意处理发布延迟""" url = f"https://api.example.com/financial/{symbol}" params = {"quarter": quarter} resp = requests.get(url, params=params) data = resp.json() # 关键:用发布日而不是报告期来对齐时间 data["publish_date"] = pd.to_datetime(data["publish_date"]) return data3.2 数据清洗的五个关键步骤
原始数据拿到手之后,不能直接用。我总结了五个必须做的清洗步骤:
第一步,处理缺失值。行情数据一般比较完整,但财务数据经常有缺失。我的做法是:对于财务指标,如果缺失就用同行业的中位数填充;如果整个行业都缺失,就用全市场的中位数。不要用均值填充,因为财务数据的分布通常是有偏的,均值会被极端值拉偏。
第二步,处理异常值。财务数据里经常出现极端值,比如某公司因为一笔一次性收益导致ROE突然变成200%。我的做法是用**MAD(中位数绝对偏差)**来识别异常值,超过5倍MAD的数值用中位数替换。为什么不用3倍标准差?因为标准差本身就会被极端值影响,MAD更稳健。
第三步,对齐时间戳。行情数据是日频的,财务数据是季频的,新闻数据是不定期的。需要统一到日频。我的做法是:财务数据在发布日当天生效,新闻数据在发布日当天生效,然后向前填充到下一个数据点。
第四步,计算衍生指标。包括动量(过去20日收益率)、波动率(过去20日收益率标准差)、换手率等。这些指标的计算窗口需要根据策略的持仓周期来定。如果你的策略平均持仓一个月,那用20日的窗口比较合适;如果持仓一周,那就用5日的窗口。
第五步,标准化。不同指标的量纲差异很大,比如ROE是百分比,市值是亿元。在输入给Agent之前,我会做截面标准化——在每个时间截面上,把所有标的的某个指标做Z-score标准化。这样Agent看到的数值都在同一个量级上,更容易做比较。
3.3 特征工程的取舍原则
特征不是越多越好。我一开始放了三十多个特征,结果Agent的推理质量反而下降了,因为信息过载让它抓不住重点。后来我精简到八个核心特征:
- 动量:过去20日收益率
- 波动率:过去20日收益率标准差
- 估值:市盈率倒数(EP)
- 盈利质量:ROE
- 成长性:营收同比增速
- 流动性:日均成交额
- 情绪:新闻情感得分(由Agent自己算)
- 资金面:北向资金净流入(如果有的话)
这八个特征覆盖了技术面、基本面、情绪面和资金面四个维度,对于日频策略来说足够了。特征太多反而容易过拟合,而且Agent处理起来也慢。
实操心得:特征的选择要跟你的持仓周期匹配。短线策略重点看动量和情绪,长线策略重点看估值和盈利质量。不要试图用一个特征集打天下。
4. Agent推理层:大模型怎么用才靠谱
4.1 Prompt设计的核心原则
用大模型做金融分析,Prompt的设计决定了输出质量的上限。我试过很多版本,最后稳定下来的Prompt遵循三个原则:
原则一:角色定义要具体。不要说“你是一个金融分析师”,而要说“你是一个专注于A股市场的量化研究员,擅长从多因子角度分析标的的短期表现”。角色越具体,模型的输出越聚焦。
原则二:输出格式要严格约束。我用的是JSON Schema的方式,在Prompt里明确列出每个字段的名称、类型和取值范围。比如:
{ "symbol": "string, 标的代码", "score": "float, 0-100之间的综合评分", "reason": "string, 不超过100字的推荐理由", "risk_flag": "string, 风险提示,没有则填'无'", "confidence": "float, 0-1之间的置信度" }原则三:提供少样本示例。在Prompt里放两到三个输入输出的示例,让模型知道你想要什么样的输出。示例的选择很关键——要覆盖不同的市场情境,比如上涨趋势、下跌趋势、震荡市各一个。
4.2 多轮推理链的设计
单轮Prompt很难让模型输出高质量的推理结果。我的做法是设计一个三轮推理链:
第一轮:信息提取。把原始数据(行情、财务、新闻)喂给模型,让它提取出关键事实。比如“过去20日涨幅15%”、“ROE为18%”、“最近一条新闻是业绩预增”。这一轮不要求模型做判断,只要求它准确地提取信息。
第二轮:逻辑推理。把第一轮提取的事实作为输入,让模型做多因子逻辑整合。比如“动量因子表现强劲,但估值偏高,情绪面偏乐观,综合来看短期有交易机会但需要控制仓位”。这一轮要求模型输出推理过程,而不是直接给结论。
第三轮:结构化输出。把第二轮的推理结果作为输入,让模型按照JSON Schema输出最终结果。这一轮只做格式转换,不做新的推理。
为什么要分三轮?因为一次性让模型做所有事情,它很容易在某个环节出错,而且出错了你也不知道是哪个环节的问题。分三轮之后,每一轮的输出都可以单独校验,问题定位起来快很多。
4.3 Function Calling的落地细节
Function Calling是让大模型输出结构化数据最可靠的方式。我定义了两个函数:
tools = [ { "type": "function", "function": { "name": "submit_stock_analysis", "description": "提交单只标的的分析结果", "parameters": { "type": "object", "properties": { "symbol": {"type": "string", "description": "标的代码"}, "score": {"type": "number", "description": "综合评分0-100"}, "reason": {"type": "string", "description": "推荐理由"}, "risk_flag": {"type": "string", "description": "风险提示"}, "confidence": {"type": "number", "description": "置信度0-1"} }, "required": ["symbol", "score", "reason", "confidence"] } } } ]调用的时候,把tools参数传给API,模型就会按照定义的格式返回结果。实测下来,这种方式比让模型直接输出JSON的稳定性高了一个数量级。唯一需要注意的是,有些模型对中文的function description支持不够好,建议用英文写description,中文写在参数值里。
4.4 并发处理与成本控制
如果你要分析几百只标的,串行调用大模型API会非常慢。我的做法是用异步并发,同时开10-20个请求。但并发数不能太高,否则容易触发API的速率限制。
成本控制方面,我做了三件事:
第一,缓存。同一只标的在同一天的分析结果缓存起来,避免重复调用。缓存的有效期设为一天,因为日频策略每天只需要更新一次。
第二,分级处理。先用简单的规则(比如动量+估值)做一个初筛,把候选池从几千只缩小到一两百只,然后再用大模型做精细分析。这样大模型的调用量就降下来了。
第三,模型选择。不是所有任务都需要用最强的模型。信息提取用轻量级模型就够了,逻辑推理用中等模型,最终的结构化输出用轻量级模型。这样搭配下来,成本可以降低60%以上。
常见问题:大模型返回的结果偶尔会出现幻觉,比如编造一个不存在的财务数据。我的解决办法是在Prompt里明确要求“只基于提供的数据进行分析,不要引入外部知识”,并且在输出之后做一个校验——如果模型提到的数值不在输入数据里,就标记为可疑结果,重新调用一次。
5. 夏普比率组合优化:从理论到代码
5.1 夏普比率优化的数学形式
组合优化的问题可以写成:
最大化:(w^T * μ - rf) / sqrt(w^T * Σ * w)
约束条件:
- sum(w) = 1(权重之和为1)
- 0 <= w_i <= 0.2(单只标的权重不超过20%)
其中w是权重向量,μ是预期收益向量,Σ是协方差矩阵,rf是无风险利率。
这个优化问题是非凸的,因为目标函数里有权重的二次型在分母上。实际求解的时候,我把它转化成一个等价的问题:固定分母,最大化分子。具体做法是引入一个辅助变量,把问题转化成二次规划。或者更简单粗暴一点,用scipy的minimize函数做数值优化,虽然慢一点但足够稳定。
5.2 预期收益和协方差矩阵的估计
预期收益的估计是组合优化里最玄学的部分。我用的是混合估计法:
- 50%权重给历史动量(过去60日收益率)
- 30%权重给Agent的评分(归一化到收益率量级)
- 20%权重给行业中性化后的估值因子
为什么这么配?因为纯历史动量的估计在趋势市里表现好,但在反转市里会亏得很惨;Agent的评分包含了更多的前瞻信息,但稳定性不如历史数据;估值因子提供长期锚定。三者混合可以在不同市场环境下都有一个不太差的表现。
协方差矩阵的估计我用的是Ledoit-Wolf收缩估计。样本协方差矩阵在标的数量多的时候非常不稳定,收缩估计通过向对角矩阵收缩,可以显著提高矩阵的条件数。实测下来,用收缩估计的优化结果比用样本协方差稳定得多。
import numpy as np from scipy.optimize import minimize from sklearn.covariance import LedoitWolf def optimize_sharpe(returns, agent_scores, rf=0.02): """ returns: 历史收益率矩阵 (T x N) agent_scores: Agent评分 (N,) rf: 无风险利率 """ n = returns.shape[1] # 估计预期收益 momentum = returns[-60:].mean(axis=0) * 252 agent_ret = (agent_scores - 50) / 50 * 0.15 # 映射到收益率量级 mu = 0.5 * momentum + 0.3 * agent_ret + 0.2 * momentum * 0.5 # 估计协方差矩阵 lw = LedoitWolf() lw.fit(returns) sigma = lw.covariance_ * 252 # 定义目标函数(负夏普比率) def neg_sharpe(w): port_ret = w @ mu port_vol = np.sqrt(w @ sigma @ w) return -(port_ret - rf) / (port_vol + 1e-8) # 约束和边界 constraints = [{"type": "eq", "fun": lambda w: np.sum(w) - 1}] bounds = [(0, 0.2) for _ in range(n)] # 初始权重:等权 w0 = np.ones(n) / n # 优化 result = minimize(neg_sharpe, w0, method="SLSQP", bounds=bounds, constraints=constraints, options={"maxiter": 1000}) return result.x5.3 权重约束的实操考量
单只标的权重上限设多少合适?我试过10%、15%、20%、30%几个档位。10%太分散,优化出来的组合跟等权差不多,失去了优化的意义;30%太集中,回撤控制不住。20%是一个比较舒服的平衡点——既能让优化算法有发挥空间,又不会让组合过度暴露在单只标的上。
另外我还加了一个行业约束:同一个行业的权重之和不超过40%。这个约束在A股特别重要,因为A股的行业轮动非常剧烈,如果不加行业约束,优化算法很容易把仓位全压在一个热门行业上,一旦行业回调,整个组合就崩了。
5.4 再平衡频率与交易成本
再平衡频率我设为每周一次。为什么不是每天?因为每天再平衡会产生大量的交易成本,而且日频的权重变化很大一部分是噪声。每周再平衡可以在跟踪效果和交易成本之间取得一个平衡。
交易成本我按**单边0.1%**来估算(包括佣金和冲击成本)。在优化的时候,我会把交易成本作为一个惩罚项加到目标函数里:
最大化:(w^T * μ - rf) / sqrt(w^T * Σ * w) - λ * sum(|w - w_old|)
其中λ是交易成本系数,w_old是上一期的权重。这样优化出来的权重不会频繁大幅变动,换手率会低很多。
6. 实操全流程:从零到跑通
6.1 环境准备与依赖安装
整个系统的依赖不算复杂,核心就是几个库:
pip install pandas numpy scipy scikit-learn requests openai tqdm如果你要用异步并发,再加一个aiohttp。如果要画图,加matplotlib。数据库我用的是SQLite,轻量够用,不需要额外安装。
目录结构我建议这样组织:
project/ ├── data/ │ ├── raw/ # 原始数据 │ └── processed/ # 清洗后的数据 ├── agent/ │ ├── prompts/ # Prompt模板 │ └── tools.py # Function Calling定义 ├── optimize/ │ └── sharpe.py # 组合优化 ├── backtest/ │ └── engine.py # 回测引擎 └── config.yaml # 配置文件6.2 数据管道的搭建与调度
数据管道我用的是每日定时任务的方式。收盘后(比如下午5点),自动触发数据抓取和清洗流程。具体步骤:
- 抓取当日行情数据,追加到行情表
- 检查是否有新的财务数据发布,如果有则更新财务表
- 抓取当日新闻,存入新闻表
- 计算衍生指标,生成特征表
- 把特征表输出为Agent可读的格式
调度我用的是Python的schedule库,简单够用。如果你要更专业的调度,可以用Airflow或者Prefect,但对于个人项目来说有点重了。
import schedule import time def daily_pipeline(): print("开始每日数据更新...") update_price_data() update_financial_data() update_news_data() compute_features() print("数据更新完成") schedule.every().day.at("17:00").do(daily_pipeline) while True: schedule.run_pending() time.sleep(60)6.3 Agent调用与结果解析
Agent调用的核心逻辑是:读取特征表,对每只标的构造Prompt,调用大模型,解析返回结果,存入结果表。
import asyncio import aiohttp import json async def analyze_stock(session, symbol, features, semaphore): async with semaphore: prompt = build_prompt(symbol, features) async with session.post( "https://api.example.com/v1/chat/completions", json={ "model": "gpt-4", "messages": [{"role": "user", "content": prompt}], "tools": tools, "tool_choice": {"type": "function", "function": {"name": "submit_stock_analysis"}} }, headers={"Authorization": "Bearer YOUR_KEY"} ) as resp: result = await resp.json() return parse_result(result) async def batch_analyze(symbols, features_dict): semaphore = asyncio.Semaphore(10) # 并发数10 async with aiohttp.ClientSession() as session: tasks = [analyze_stock(session, s, features_dict[s], semaphore) for s in symbols] results = await asyncio.gather(*tasks) return results并发数我设的是10,实测下来这个数字比较稳,再高就容易触发速率限制。如果你用的是付费额度比较高的账号,可以适当调高。
6.4 组合优化与回测验证
拿到Agent的评分之后,就可以跑组合优化了。回测的时候要注意几个点:
第一,避免未来函数。Agent的评分必须用当天之前的数据生成,不能用未来的信息。我在回测框架里加了一个时间戳校验,如果发现用了未来数据就直接报错。
第二,考虑交易成本。每次调仓都要扣除交易成本,否则回测结果会虚高。
第三,样本外测试。不要只看样本内的回测结果,一定要留出一段样本外的时间做验证。我的做法是:用2020-2022年的数据做样本内优化,用2023-2024年的数据做样本外验证。
def backtest(start_date, end_date, rebalance_freq="W"): """简单回测框架""" dates = pd.date_range(start_date, end_date, freq=rebalance_freq) portfolio_value = 1.0 weights = None for i, date in enumerate(dates): # 获取当日Agent评分 scores = get_agent_scores(date) returns = get_historical_returns(date) # 优化权重 new_weights = optimize_sharpe(returns, scores) # 计算交易成本 if weights is not None: turnover = np.sum(np.abs(new_weights - weights)) cost = turnover * 0.001 # 单边0.1% portfolio_value *= (1 - cost) # 计算下一期收益 next_returns = get_next_period_returns(date) port_return = new_weights @ next_returns portfolio_value *= (1 + port_return) weights = new_weights return portfolio_value6.5 结果可视化与监控
回测结果我一般看几个图:净值曲线、回撤曲线、夏普比率滚动窗口、换手率。净值曲线看整体表现,回撤曲线看风险控制,滚动夏普看策略稳定性,换手率看交易成本。
监控方面,我设了几个预警:如果单日回撤超过3%,发邮件提醒;如果连续5个交易日跑输基准,发邮件提醒;如果Agent的调用失败率超过10%,发邮件提醒。这些预警不需要很复杂,用Python的smtplib就能实现。
7. 踩坑记录与常见问题排查
7.1 大模型输出不稳定的五种表现
表现一:JSON格式错误。模型有时候会在JSON里加注释,或者用单引号代替双引号。解决办法是用Function Calling,或者用json.loads之前先做正则清洗。
表现二:数值幻觉。模型会编造不存在的财务数据。解决办法是在Prompt里强调“只基于提供的数据”,并且在输出后做校验。
表现三:评分分布偏移。模型给的评分有时候集中在某个区间,区分度不够。解决办法是在Prompt里明确评分的分布要求,比如“评分应该在0-100之间均匀分布,不要集中在50附近”。
表现四:推理链断裂。模型在第二轮推理时忘记第一轮提取的事实。解决办法是把第一轮的输出显式地放在第二轮的Prompt里,而不是让模型自己回忆。
表现五:对负面信息过度反应。模型看到一条负面新闻就容易给很低的评分。解决办法是在Prompt里加入“请综合考虑多维度信息,不要因为单一负面事件就给出极端评分”的指令。
7.2 组合优化的数值不稳定问题
问题一:协方差矩阵不可逆。当标的数量接近或超过历史数据长度时,协方差矩阵会变成奇异矩阵。解决办法是用收缩估计,或者减少标的数量。
问题二:优化结果对初始值敏感。SLSQP算法有时候会陷入局部最优。解决办法是跑多次优化,每次用不同的随机初始值,取夏普比率最高的那个。
问题三:权重极端化。优化算法有时候会把某只标的的权重顶到上限。解决办法是加一个权重的熵正则项,鼓励权重分散:
最大化:夏普比率 - γ * sum(w_i * log(w_i))
其中γ是正则化系数,控制分散程度。
7.3 数据质量的隐蔽陷阱
陷阱一:幸存者偏差。如果你用的标的池是当前还在交易的标的,那就剔除了已经退市的标的,回测结果会虚高。解决办法是用历史全量的标的池,包括已经退市的。
陷阱二:前视偏差。财务数据在发布之前你是不知道的,但很多数据源会把报告期的数据直接给你。解决办法是用发布日而不是报告期来对齐时间。
陷阱三:复权处理。行情数据有前复权、后复权、不复权三种。做回测必须用前复权,否则分红送股会导致价格跳空,影响收益率计算。
陷阱四:停牌处理。停牌期间没有行情数据,如果不处理,收益率计算会出错。我的做法是停牌期间权重保持不变,复牌后按复牌价计算收益。
7.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| Agent返回空结果 | API调用失败 | 检查网络和API密钥 | 重试或切换API端点 |
| 评分区分度低 | Prompt约束不够 | 统计评分分布 | 在Prompt中明确分布要求 |
| 回测夏普异常高 | 存在未来函数 | 检查数据时间戳 | 用发布日对齐时间 |
| 优化权重全压一只 | 预期收益估计偏差大 | 检查μ的数值范围 | 加权重上限和熵正则 |
| 换手率过高 | 再平衡频率太高 | 统计每日权重变化 | 降低再平衡频率或加成本惩罚 |
| 协方差矩阵报错 | 标的数大于样本数 | 检查矩阵维度 | 用收缩估计或减少标的 |
实操心得:回测结果如果夏普比率超过3,先别高兴,大概率是有bug。A股市场长期夏普比率在0.5-1.5之间是比较合理的范围,超过2就要仔细检查是不是有未来函数或者幸存者偏差。
8. 系统扩展与进阶方向
8.1 多模态信息的引入
目前系统只用了文本信息,但实际上还有很多非文本信息可以利用。比如财报电话会议的音频,可以通过语音转文字之后让大模型分析管理层的语气和措辞变化。再比如卫星图像数据,可以分析工厂的开工率、停车场的车辆密度等。这些多模态信息在传统量化里很难处理,但大模型的多模态能力可以很好地消化。
我试过用大模型分析财报电话会议的转录文本,让它判断管理层的信心程度。实测下来,这个信号和后续的股价表现有一定的相关性,但噪声也比较大,需要和其他因子配合使用。
8.2 Agent记忆机制的引入
目前的Agent是无状态的,每次分析都是独立的。如果引入记忆机制,让Agent记住之前对某只标的的判断,以及判断之后的实际表现,它就可以在后续的分析中参考这些历史经验。这其实就是给Agent加了一个反思循环。
实现方式可以是在数据库里存一个“Agent判断历史表”,每次分析之前把该标的过去N次的判断和实际结果作为上下文喂给模型。这样模型可以看到自己之前的判断是对是错,从而调整当前的判断。
8.3 风险模型的精细化
目前的组合优化只用了波动率作为风险度量,但实际上风险有很多维度:下行风险、尾部风险、流动性风险、集中度风险。可以引入**CVaR(条件风险价值)**作为补充的风险约束,限制组合在极端情况下的最大损失。
CVaR的优化比夏普比率复杂一些,因为它涉及到分布的分位数计算。一个简化的做法是用历史模拟法,计算组合在历史上最差的5%情况下的平均损失,然后把这个作为约束加到优化问题里。
8.4 实盘对接的注意事项
如果你要把这个系统对接实盘,有几个事情必须提前做好:
第一,风控前置。在订单发出之前,先过一遍风控规则:单只标的权重是否超限、行业集中度是否超限、当日交易金额是否超限。风控不通过就不发单。
第二,订单拆分。如果单笔订单金额较大,直接市价单会有很大的冲击成本。需要拆成多笔小单,用TWAP或者VWAP算法执行。
第三,异常处理。网络断了怎么办、API挂了怎么办、订单被拒了怎么办。这些异常情况必须有预案,不能等到发生了再临时处理。
第四,对账机制。每天收盘后,把系统的持仓和实际账户的持仓做对账,确保一致。如果不一致,要立即排查原因。
提示:实盘和回测的差距往往比想象中大。回测里假设的成交价是收盘价,但实盘里你可能只能拿到收盘前几分钟的价格,这个差异在流动性差的标的上会非常明显。建议在回测里加入滑点假设,比如买入时成交价上浮0.1%,卖出时下浮0.1%。
9. 一些个人体会
这个系统我从最初的想法到跑通完整流程,大概花了三个月的时间。其中大部分时间不是在写代码,而是在调Prompt和排查数据问题。大模型的能力确实很强,但它不是一个即插即用的组件,你需要花很多时间去理解它的脾气——它在什么情况下会出错、什么样的Prompt能让它稳定输出、什么样的任务适合它做、什么样的任务不适合。
夏普比率优化这部分,数学上很成熟,但实操中的坑主要在于参数估计。预期收益估不准,优化出来的权重就是空中楼阁。我的经验是,不要追求最优解,追求稳健的次优解。加权重上限、加行业约束、加交易成本惩罚,这些约束看起来让优化结果变差了,但实际上让策略在实盘中的表现更稳定。
最后分享一个小技巧:如果你觉得大模型的调用成本太高,可以先用规则做一个粗筛,把候选池缩小到50只以内,然后再用大模型做精细分析。这样成本可以降低一个数量级,而效果几乎不受影响。因为大部分标的在粗筛阶段就可以被排除掉,不需要大模型出手。