简介:面向投资者、量化研究员和AI开发者的智能股票筛选系统,整合技术分析与大语言模型能力:基于历史价格和成交量计算移动平均线、RSI、布林带等指标,同时利用新闻、财报等文本数据捕捉潜在行情因素,最终输出投资建议与风险评估,用户可根据风险偏好调整筛选参数。资源包共20个文件,约42KB,以Python脚本为主(11个py),涵盖股票数据爬虫、新闻情感分析、量化分析、模型处理、配置与数据库管理等模块,另含Markdown说明文档及requirements依赖文件,便于快速还原运行环境。已有165人学习下载,适合希望快速上手AI选股项目或参考其系统设计的读者。借助该系统能快速理解大模型如何辅助投资决策,其中代码模块划分清晰,也可根据自身需求修改参数与数据源,作为二次开发或教学演示的基础,适合学习AI与传统量化结合的实现思路。
1. Ai Stock Selector 到底筛的是哪一类股票
很多人以为 Ai Stock Selector 是“AI 直接报代码”,输入一句需求就吐出几只股票。实际工程里,它是一条每天收盘后自动跑完的数据管线:先用规则把全市场几千只股票压缩到几百只候选,再用模型打分排序,产出 TopN 列表交给交易规则执行。它替代的是翻公告、盯行情、凭感觉圈自选股的体力活,而不是交易员本身。
它要解决的核心问题是候选池收敛。一个人能持续跟踪的自选股通常不到 50 只,可交易对象却多出一个数量级。把“看动量、看量价、看财务质地”这类直觉固化成可回测的特征与评分,才是智能股票筛选系统的真正价值。
这套系统适合个人量化研究者、给投研团队搭选股管线的后端工程师,以及想从固定指标筛选升级到机器学习打分但不希望在数据上反复返工的人。先给一个反直觉的结论:这类系统的第一瓶颈往往不是模型精度,而是标签定义与数据对齐;这两件事不扎实,后面调多少参数都白搭。
2. Ai Stock Selector 的数据管道与因子选型
2.1 先决定“筛什么”,再决定“存什么”
做智能股票筛选,第一步不是找模型,而是把“筛选任务”翻译成存储结构。最常见的形态是横截面选股:每个交易日收盘后,对全市场同一批股票用同一套特征打分。对应到数据层就是宽表加日期索引,我一般拆成三层:日线行情层、估值快照层、股票基础信息层。
日线行情层放 OHLCV、成交额、换手率,量价因子都从这里出;估值快照层放 PE、PB、总市值、流通市值,注意这些指标每天更新的是价格分母,财务分子只在财报发布日变化;股票基础信息层放行业、上市日期、ST 状态,主要用来过滤和做分组中性化。三层统一用“股票代码 + 交易日期”做联合主键,代码写成带交易所前缀的形式,避免沪市 600 开头和深市 000 开头撞号。
| 数据层 | 关键字段 | 更新频率 | 对应因子 |
|---|---|---|---|
| 日线行情 | open / high / low / close / volume / amount | 每交易日收盘后 | 动量、波动率、均线偏离 |
| 估值快照 | pe / pb / ps / 总市值 / 流通市值 | 每交易日收盘后 | 估值截面因子 |
| 股票基础信息 | 行业 / 上市日期 / ST 状态 | 每周 | 过滤条件、行业中性化 |
提示:市盈率这类字段虽然按日落库,但它的分子(净利润)是滞后的。如果直接拿“今天看到的 PE”当特征,在不发财报的月份没问题,在财报季会因为数据源更新时点差异引入看点偏差。更稳妥的做法是在基础信息表里记录财报发布日期,特征构建时按发布日期对齐。
2.2 最小可用因子库:从三类因子起步
因子并非越多越好。几十个因子的表格型数据,树模型足够消化;一上来堆几百个因子,先倒下的往往是数据校验而不是模型。我习惯先固定三类因子,跑通端到端链路后再扩展。
- 动量类:过去 5 / 20 / 60 个交易日收益,以及收盘价对 20 日均线的偏离度。这类因子把“趋势”量化为数值。
- 波动与流动性类:20 日收益率滚动标准差、20 日平均换手率、20 日平均成交额。换手率与成交额用来过滤流动性不足的标的,避免买入容易卖出难。
- 估值类:PE、PB 在当日全市场的截面百分位。用百分位而不是原始值,是因为不同行业估值中枢差异很大;更严格一点,先按行业分组再算百分位。
这里有一条贯穿全系统的约束:任何因子在 T 日只能使用 T 日收盘前已经发生的数据。动量、均线天然满足;财务类因子要对齐“发布日期”。这个约束叫 point-in-time,它直接决定回测结果是否可信,也是后面排错时第一个要查的地方。
2.3 用 Python 对齐日线数据并落盘
数据源无论用免费接口还是商业行情库,落到本地后第一件事是统一 schema。下面这段是常用的清洗函数:把日线表和基础信息表按股票代码合并,过滤上市不足 90 天的次新股,并保证输出按股票和日期排序。
import pandas as pd def load_and_align(daily: pd.DataFrame, basic: pd.DataFrame) -> pd.DataFrame: # daily: 列 = [date, code, open, high, low, close, volume, amount] daily["date"] = pd.to_datetime(daily["date"]) basic["list_date"] = pd.to_datetime(basic["list_date"]) # inner join 只保留有基础信息的股票,顺便带上行业与上市日期 df = daily.merge( basic[["code", "industry", "list_date", "is_st"]], on="code", how="inner" ) # 过滤上市不满 90 天的股票,避免次新股因子失真 df = df[df["date"] >= df["list_date"] + pd.Timedelta(days=90)] # 去掉 ST 标记的标的,风控常会把它们排除在候选池外 df = df[df["is_st"] == 0] return df.sort_values(["code", "date"]).reset_index(drop=True)逻辑说明:merge 用 inner 连接,带不出基础信息的行情行直接丢弃,这类行多半是退市整理期或代码停用;上市满 90 天是过滤次新股的保守阈值,短线策略可以放宽到 60 天,长线可以放到 250 天;is_st 过滤放在数据层而不是模型层,是为了让后续所有环节共享同一个候选池,避免模型和数据源各自维护一套过滤逻辑。
参数说明:daily 里多余的列不会影响 merge,但建议只保留需要的列,后续因子计算会多次读写这张表,列太多会拖慢 parquet 落盘速度。
增量更新的简便做法
全量重算只适合第一次建表。日常运行只拉最新一个交易日的数据,写入按日期分区的 parquet,重算因子时只扫最近 60 个交易日的分区,再和全量历史拼接。跑数任务要保证幂等:同一 (code, date) 重复写入多次,结果必须一致,否则回测和实盘会悄悄分叉。现在用 ai 编程工具生成这种清洗脚本很快,但生成完不校验唯一键,照样跑出错数据,所以唯一键约束必须写进任务本身。
3. Stock Selector 的核心打分:标签、特征与模型
3.1 标签定义:把“涨得好”变成可学习的 y
预测什么,比用什么模型更重要。常见的做法是预测未来 5 个交易日的收益,是否进入当日全市场的前 20%。用排名而不是绝对收益的好处是剥离大盘贝塔:牛市里绝大多数股票都涨,绝对收益做标签会让正样本过多;用截面排名,任何行情下都能产生稳定的正负样本分布。
def make_label(df: pd.DataFrame, horizon: int = 5, top_ratio: float = 0.2) -> pd.DataFrame: df = df.sort_values(["code", "date"]).copy() # 未来 horizon 日的收益:用 groupby 后 shift(-horizon) 的收盘价计算 df["close_future"] = df.groupby("code")["close"].shift(-horizon) df["future_ret"] = df["close_future"] / df["close"] - 1 # 每个交易日当天,按未来收益从高到低排名,取前 top_ratio 作为正样本 df["rank"] = df.groupby("date")["future_ret"].rank(pct=True, ascending=True) df["label"] = (df["rank"] >= 1 - top_ratio).astype(int) return df[df["future_ret"].notna()]逻辑说明:shift(-horizon)把每只股票的收盘价向上移动 5 行,得到 5 个交易日后才可知的价格,两者相减就是“未来收益”。groupby("date").rank(pct=True)在每个交易日内做百分位排名,数值越接近 1 表示收益排名越靠前。最后丢弃future_ret为空的行,因为样本最后 5 个交易日没有未来数据可用。
参数说明:horizon=5对应周度调仓;top_ratio=0.2表示每天选收益前 20% 的股票当正样本,剩下 80% 是负样本。如果改成 0.1,正样本更少更精,但类别不平衡会加剧,需要同步调整采样和评估指标。
标签窗口与调仓周期的关系
horizon 必须和再平衡周期一致:周度调仓用 5 日,双周用 10 日。窗口太短,标签里噪声大,模型学到的是随机扰动;窗口太长,相邻样本的重叠度高,训练集和验证集之间容易泄漏。经验做法是检查样本重叠率,不重叠样本占比低于 30% 时,把 horizon 调大或把采样间隔拉长。
3.2 用 LightGBM 排序模型做候选池打分
选模型的标准是匹配数据形态:几十个数值因子、几十万行样本,树模型对量纲不敏感、自带缺失值处理、训练速度快。很多人一上来想用 ai 大模型做筛选,但几十个数值特征丢给 LLM 既不划算也不可控,表格数据上 GBDT 仍然是默认起点。等因子维度上千、样本到百万级,再考虑深度模型不迟。
import lightgbm as lgb features = ["ret_5", "ret_20", "ret_60", "vol_20", "turnover_20", "pe_pct", "pb_pct", "amount_20"] model = lgb.LGBMClassifier( n_estimators=500, learning_rate=0.05, num_leaves=31, max_depth=6, min_child_samples=100, subsample=0.8, colsample_bytree=0.8, reg_lambda=1.0, random_state=42, n_jobs=-1, ) model.fit( X_train, y_train, eval_set=[(X_valid, y_valid)], callbacks=[lgb.early_stopping(50), lgb.log_evaluation(50)], )逻辑说明:这里用二分类器预测“是否进入未来收益前 20%”,输出概率作为打分。early_stopping(50)在验证集上连续 50 轮没有改善就停止训练;验证集只用来早停和粗调,测试集在全部调参结束后只碰一次。
| 参数 | 建议起点 | 调整方向 |
|---|---|---|
| learning_rate | 0.05 | 过拟合时降到 0.02,同时加大 n_estimators |
| num_leaves | 31 | 叶子数越大模型越复杂,小样本从 15 开始 |
| min_child_samples | 100 | 样本不足时提高到 200,抑制噪声样本 |
| subsample / colsample_bytree | 0.8 / 0.8 | 行、列采样,配合 early_stopping 防过拟合 |
| reg_lambda | 1.0 | 特征共线性高时加大到 5~10 |
参数说明:股票因子的相关性普遍偏高,比如ret_5和ret_20在趋势行情里几乎同涨同跌,所以reg_lambda比常规分类任务更需要重视。num_leaves不要照搬默认值 31,样本量只有几万时,15 以下更安全。
3.3 输出 TopN 与再平衡规则
打分之后接执行层:每天取概率最高的前 20 只,等权持有;每周最后一个交易日收盘后再打分,重新生成持仓。这里的关键是排序必须按交易日分组,不能把不同日期的概率放在一起排序,因为模型输出的概率分布在每天并不一致。
def select_top(df, model, features, top_n=20): df = df.copy() # 第二列是正样本的预测概率 df["score"] = model.predict_proba(df[features])[:, 1] today = df["date"].max() # 只对最新交易日做截面排序,取前 top_n ranked = df[df["date"] == today].sort_values("score", ascending=False) return ranked.head(top_n)逻辑说明:先复制一份 DataFrame,避免在原始数据上追加列;predict_proba返回两列,第二列是正样本概率;df["date"].max()取最新交易日,保证排序限定在同一个截面上。
再平衡规则我一般加缓冲带:持有 Top 20,但只有股票跌出 Top 30 才卖出,新进入 Top 10 的才买进。缓冲带把换手率压下来,避免股票在阈值边缘抖动造成反复买卖。等权配置在股票数量 20 只左右时足够分散,不需要额外做风险平价;等资金规模变大或标的扩到 50 只以上,再考虑行业暴露约束。
4. 回测与过拟合排查:Stock Selector 上实盘前的最后一关
4.1 Walk-forward 切分的正确姿势
股票日线是强时间序列,随机 K 折会把未来数据混进训练集,回测出来夏普再高都是假的。正确做法是 walk-forward:用截止到 T 日的数据训练,在 T 之后的窗口里预测,然后滚动前移。下面这套骨架每 63 个交易日重训一次,相当于季度更新模型。
def walk_forward(df, features, start="2018-01-01", step_days=63): train_end = pd.Timestamp(start) out = [] while train_end < df["date"].max(): test_win = df[(df["date"] > train_end) & (df["date"] <= train_end + pd.Timedelta(days=step_days))] if len(test_win) == 0: break train_win = df[df["date"] <= train_end] model = lgb.LGBMClassifier(n_estimators=200, learning_rate=0.05, num_leaves=31) model.fit(train_win[features], train_win["label"]) test_win = test_win.copy() test_win["score"] = model.predict_proba(test_win[features])[:, 1] out.append(test_win) train_end += pd.Timedelta(days=step_days) return pd.concat(out, ignore_index=True)逻辑说明:train_win始终使用train_end之前的数据,test_win严格排在训练截止日之后,代码里用date > train_end强制隔离,从结构上杜绝时间泄漏。
参数说明:step_days=63约等于 3 个月,和再平衡周期要区分开——模型可以每季度重训,但打分和调仓仍按周进行。train_end的起点要留出至少 2 年历史当初始训练集,太短的话第一个窗口里的模型还没收敛,前几段回测结果没有参考价值。
4.2 回测指标看哪几个,怎么设阈值
回测报告别只看年化收益。我一般按下面这张表逐项核对,其中最大回撤和换手率比收益更先暴露问题:
| 指标 | 计算口径 | 参考阈值 |
|---|---|---|
| 年化收益 | 组合净值年化 | 相对基准看超额,不单看绝对值 |
| 夏普比率 | (年化收益 - 无风险利率) / 年化波动 | 低于 1 不建议继续调参 |
| 最大回撤 | 净值峰谷最大回撤 | 先和策略能承受的止损线比对 |
| 月换手率 | 再平衡买卖金额 / 持仓市值 | 超过 300% 说明边界抖动严重 |
| TopN 胜率 | 持仓中收益为正的股票占比 | 与基准同期胜率对比看 |
换手率这条最容易忽略。模型每周打分,如果 Top 20 名单每周更换 15 只,月换手率轻松超过 400%,交易成本会吃掉大部分超额收益。出现这种情况,先加缓冲带,再检查是不是特征里混入了过高频的噪声因子,而不是急着改模型。
4.3 三个最隐蔽的翻车点
未来函数:T 日打分,成交价却用了 T 日
用 T 日收盘数据打分,却假设能按 T 日收盘价成交,这是回测里最常见的未来函数。收盘价在收盘瞬间才能确定,按它成交意味着你拥有了“收盘那一刻才产生的信息”。常见修法:T 日收盘后打分,成交价用 T+1 日开盘价;更保守的做法是整体把信号往后推一天,用 T+1 收盘价成交。
截面标签的跨期污染
训练时把未来收益排名当特征喂进去,模型会“记住”答案。这类错误通常发生在特征拼接时:make_label生成的rank和future_ret列没有丢弃,宽表里所有列被一起塞进训练集。修法是特征列显式列出来,训练前对标签相关列做 drop,不要在 DataFrame 里留任何以 future 开头的列。
幸存者偏差
只用“现在还活着”的股票回测,等于默认历史上退市的股票从一开始就不存在,这会显著抬高收益。修法是在基础信息表里保留历史上曾经挂牌的代码,回测的过滤条件只判断“当天是否上市、是否停牌”,不判断“今天是否还在交易”。数据源如果默认只提供当前成分股,要额外补历史退市列表。
5. 用 Ai Stock Selector 生成每日信号流的最后一步
5.1 把打分流程封装成幂等的每日任务
模型验证完,剩下的工作是把打分变成每天收盘后可重复执行的作业。这个任务放在调度器里每天跑一遍,输出一个按日期命名的信号文件,就是一个最小形态的智能筛选闭环——数据更新、因子计算、模型打分、信号落盘四步串起来,后续接模拟交易就是 AI Agent 里的执行器。
def daily_signal(today: str) -> pd.DataFrame: daily = load_daily(today) # 增量读取当日行情 aligned = load_and_align(daily, basic) # 复用清洗函数 feats = build_factors(aligned) # 只使用截至 today 的数据 feats["score"] = model.predict_proba(feats[factors])[:, 1] top = (feats.sort_values(["date", "score"], ascending=False) .groupby("date").head(20)) top.to_parquet(f"signal/{today}.parquet", index=False) return top逻辑说明:load_and_align复用了第 2 章的清洗函数,保证实盘信号和回测用的是同一套候选池。to_parquet按日期覆盖写,任务重复执行不会产生脏数据,这就是幂等。
5.2 上线第一周要盯的三项验证
实盘前先在模拟环境跑至少一周,每天开盘前核对三件事:候选列表是否与前一交易日收盘打分一致;买入名单里有没有停牌股或临时 ST 的股票;模拟账户的净值方向是否与回测同期同向。其中第三项最容易被忽略——方向一致不等于收益一致,但方向相反基本可以断定信号链路有偏差。
再加一个打分分布监控:每天记录模型输出概率的均值、分位数,一旦某日打分整体偏移,先查数据源当日是否缺失,再查特征均值是否漂移。这类监控脚本半小时能写完,但它决定了模型什么时候该重训、什么时候是数据坏了。
最后留一句工程上的收尾动作:在把 T+1 成交价和真实滑点接进回测引擎之前,不要急着再调 num_leaves——大多数“实盘不如回测”的问题,都出在这两个字段上。
本文还有配套的精品资源,点击获取