Sequoia-X 项目研究站 · 新增策略
Sequoia-X 新增策略:四步把私有逻辑接进流水线
这套系统的扩展点很明确:继承 BaseStrategy、实现 run() 返回股票代码列表、给类设一个 webhook_key、把它加进 main.py 的策略列表。做完这四步,它就会和其他七个策略一样,在每天补完增量之后自动执行,命中结果推到对应的飞书群。
Sequoia-X 的 BaseStrategy 契约是什么?
约束很少,但每一条都有明确用途。
| 契约项 | 定义 | 适用场景 | 注意点 |
|---|---|---|---|
| 基类 | class BaseStrategy(ABC),用抽象方法强制子类实现 | 所有具体策略的共同父类 | 不实现 run() 的类无法实例化 |
| 抽象方法 | @abstractmethod def run(self) -> list[str] | 执行选股逻辑的入口 | 返回空列表表示当天没有命中,不会触发推送 |
| 类属性 | webhook_key: str = "default" | 决定推送到哪个飞书群 | 子类覆盖它(如 "rps"),再在 .env 里配同名键 |
| 构造参数 | __init__(self, engine: DataEngine, settings: Settings) | 拿到数据引擎与配置 | 源码把它存成 self.engine / self.settings,新策略直接复用 |
| 异常边界 | 现有策略在逐只循环内 try/except 并记 warning | 单只标的异常不应影响全市场扫描 | 这是既有实现的写法约定,新策略建议保持一致 |
| 日志 | 用 get_logger(__name__) 取命名 logger | 输出可追溯的执行记录 | 同名 logger 幂等,不会重复叠加 handler |
给 Sequoia-X 接自定义策略要走哪五步?
下面的骨架与现有策略结构一致,命名与注释风格照仓库保持。
新建策略文件
在
sequoia_x/strategy/下新增my_signal.py,导入BaseStrategy、get_logger与 pandas。文件名与类名风格参照现有策略(如ma_volume.py对应MaVolumeStrategy)。实现 run() 并返回代码列表
用
self.engine.get_local_symbols()拿全市场代码,逐只self.engine.get_ohlcv(symbol)取日 K,做向量化计算后把命中的代码append进列表返回。需要 60 日均线这类长窗口时,先判断len(df)是否够。设置 webhook_key
给类加一行
webhook_key = "my_signal",并在.env里补STRATEGY_WEBHOOK_MY_SIGNAL(不配则回落到默认 Webhook,也能跑)。注册到 main.py
在导入区加一行 import,在
strategies列表里追加实例YourSignalStrategy(engine=engine, settings=settings)。列表顺序就是执行顺序。跑一次验证
先跑
python main.py看日志里是否出现「YourSignalStrategy 选出 N 只股票」,再确认命中的推送落在了预期群里。空命中时只会有日志、不会有卡片。
ma_volume.py 是最短的一份(约 60 行),适合当模板;rps_breakout.py 展示了「一次性读全表做横截面排名」的另一种取数方式。把这两个文件对着读一遍,比从零设计快得多。Sequoia-X 策略有哪四条写法约定?
这些约定来自现有七个策略的一致写法,不是硬性限制,但破坏它们会带来实际问题。
| 约定 | 具体做法 | 适用场景 | 破坏后的后果 |
|---|---|---|---|
| 向量化计算 | 用 rolling / shift / rank 等 pandas 方法,源码注释直接写「严禁 iterrows」 | 全市场逐只计算 | 逐行遍历会让日常运行从分钟级退化到不可接受 |
| 窗口隔离 | 比较基准要排除当日,例如 high.shift(1).rolling(20).max() | 判断「突破前 N 日高点」这类条件 | 不隔离会形成「当日包含自身」的自指比较,条件恒成立 |
| 最小样本 | 用类常量声明(如 _MIN_BARS)并提前 continue | 需要 20 / 40 / 60 根窗口的策略 | 窗口不足时得到 NaN,静默漏选或报错 |
| 单只容错 | 逐只 try/except,异常写 warning 后 continue | 个别标的脏数据 | 一个异常会让整个策略中断,后续股票全部漏算 |
| 可调参数外置 | 用类属性(如 rps_period = 120)而不是函数内魔法数字 | 需要试验不同阈值时 | 参数埋在下标里时很难批量调整与比对 |
| 结果不排序或明确排序 | 要排序就写明依据(如海龟按流通市值降序),不排就保持遍历顺序 | 结果可解释性 | 隐式顺序会让人误以为清单有优先级含义 |
Sequoia-X 的两条取数路径分别是什么?
现有策略里两种写法都有,选错了要么慢要么算不出。
| 对比项 | 逐只读库 get_ohlcv | 一次性读全表 read_sql | 适用场景 |
|---|---|---|---|
| 调用方式 | self.engine.get_ohlcv(symbol),内部按 symbol 查询并排序 | sqlite3.connect(self.engine.db_path) + pd.read_sql | 前者适合形态类,后者适合横截面类 |
| 内存占用 | 每次一只,峰值小 | 整表进内存,随库增长 | 机器内存有限时优先前者 |
| 能否做横向排名 | 不能(看不到其他标的) | 可以,如 rank(pct=True) | 相对强度、行业内排名这类需求 |
| 现有例子 | 海龟、均线放量、高窄旗形、涨停洗盘、上升跌停 | RPS 突破 | 可以在仓库里各找一份对照 |
| 异常表现 | 包在逐只 try 里,异常不影响其他标的 | 读取失败时整体返回空列表 | 整表读取的失败面更大 |
| 额外联网 | 一般不联网(海龟查市值是例外) | 不联网 | 定增公告策略则完全走外部接口,不走库 |
Sequoia-X 的测试与代码规范怎么落地?
仓库自带测试与静态检查配置,扩展时建议沿用。
| 项目 | 配置 | 适用场景 | 注意点 |
|---|---|---|---|
| 测试框架 | pytest(testpaths = ["tests"]) | 运行 pytest 即可执行全部测试 | 新增策略建议在 tests/ 下补对应用例 |
| 属性测试 | hypothesis(dev 依赖 >=6.100) | 对边界条件做随机化验证 | 阈值类策略尤其适合验证「窗口不足时的行为」 |
| 模拟对象 | pytest-mock(dev 依赖 >=3.12) | 替换数据引擎与网络请求 | 现有测试文件名可按模块对照:config / data_engine / feishu / logger / main / strategy |
| 代码检查 | ruff:line-length = 100、target-version = "py311"、select = ["E","F","I","UP"] | 保持风格一致 | import 顺序(I)与语法升级(UP)也在检查范围内 |
| Python 版本 | requires-python >= 3.10,ruff 目标 py311 | 类型标注与语法可用范围 | 写 list[str] 这类内置泛型标注需要 3.9+ |
| 依赖锁定 | 仓库含 uv.lock | 可复现的环境 | 新增第三方依赖时应同步更新锁文件 |
新策略写完怎么验证:一段可运行的后验脚本骨架
项目不含回测引擎,所以验证要自己写。下面这段骨架只做一件事:把历史信号取出来,按「信号日 → 次日开盘买入 → 持有 N 个交易日」算后验收益,并逐笔扣费。它最大的价值是强制你把成交口径写死。
import sqlite3, pandas as pd
HOLD_DAYS = [1, 5, 10, 20] # 持有周期(交易日)
FEE, TAX, SLIP = 0.00025, 0.0005, 0.001 # 佣金 / 卖出印花税 / 单边滑点(按你的券商改)
conn = sqlite3.connect("data/sequoia_v2.db")
bars = pd.read_sql(
"SELECT symbol, date, open, high, low, close, volume, turnover "
"FROM stock_daily ORDER BY symbol, date", conn)
def find_signals(bars):
# 把你的策略条件写在这里;硬约束:只能用当日及之前的数据
out = []
for symbol, g in bars.groupby("symbol"):
if len(g) < 21:
continue
g = g.copy()
g["high_20"] = g["high"].shift(1).rolling(20).max() # shift(1) 防未来函数
for i in range(20, len(g) - 1): # 留出次日,保证能成交
row, prev = g.iloc[i], g.iloc[i - 1]
if (row["close"] > row["high_20"] and row["turnover"] > 1e8
and row["close"] > row["open"] and row["close"] > prev["close"]):
out.append((symbol, i)) # (股票, 信号在序列中的位置)
return out, bars
def forward_return(g, i, n):
# 次日开盘买入 → 第 n 个交易日开盘卖出(若样本不够则丢弃)
if i + n >= len(g):
return None
buy, sell = g["open"].iloc[i + 1], g["open"].iloc[i + n]
gross = sell / buy - 1
cost = FEE * 2 + TAX + SLIP * 2 # 双边佣金 + 卖出印花税 + 双边滑点
return gross - cost
signals, bars = find_signals(bars)
rows = []
for symbol, i in signals:
g = bars[bars["symbol"] == symbol]
for n in HOLD_DAYS:
r = forward_return(g, i, n)
if r is not None:
rows.append({"symbol": symbol, "signal_idx": i, "hold": n, "ret": r})
res = pd.DataFrame(rows)
print(res.groupby("hold")["ret"].agg(["count", "mean", "median",
lambda s: (s > 0).mean()]).rename(columns={"<lambda_0>": "win_rate"}))
| 口径决策 | 两种常见写法 | 本站建议与理由 |
|---|---|---|
| 信号日定义 | 收盘价算信号 / 盘中触及算信号 | 用收盘价算——本项目所有策略都读最后一根 K 线,盘中触及无法用日线复现 |
| 成交价 | 信号日收盘价 / 次日开盘价 / 次日均价 | 次日开盘价最保守也最容易复现;用信号日收盘价买入等于偷了一个交易日 |
| 持有周期 | 固定 N 个交易日 / 固定止盈止损 | 先固定 N(1/5/10/20)分别统计,再考虑加止盈止损;项目本身不含止损逻辑 |
| 成本 | 不加成本 / 只加佣金 / 佣金+印花税+滑点 | 三项都加。形态类信号常出现在放量或涨停附近,零滑点会系统性高估 |
| 异常样本 | 全部计入 / 剔除涨跌停无法成交的样本 | 先全部计入跑一遍,再单独统计「买入日一字涨停」这类无法成交的样本占比 |
| 基准 | 无基准 / 与宽基指数同期对比 | 库里没有指数数据(stock_type=1 已过滤),基准要另外取,否则无法区分「策略有效」与「市场给的」 |
用这个库做回测会遇到哪些陷阱?
下面每一条都能让回测结果「好看」但不可用。它们的共同点:不在策略条件里,而在数据与成交假设里。
| 陷阱 | 为什么发生 | 自检方法 | 修正做法 |
|---|---|---|---|
| 未来函数 | 用信号日收盘价买入,或让特征用到当日之后的数据(例如把 rolling 写成了居中的窗口) | 把回测里的「买入价」临时改成次日开盘价,看收益是否大幅下滑——若下滑剧烈,说明原结果依赖未来数据 | 买入价统一取 open.iloc[i+1];所有滚动窗口一律 shift(1) 后再 rolling |
| 幸存者偏差 | 股票池来自 get_all_symbols(),只含当前上市股票,退市股从未入库 | 统计库内股票数是否明显少于同期真实上市家数 | 在结论里显式声明股票池口径;涉及长期持有结论时补入退市样本 |
| 停牌日错位 | 入库过滤 volume = 0,「第 N 个交易日」在停牌股上其实是第 N 个有成交的日子 | 对命中票统计其 K 线根数与区间交易日数量的差 | 用日期索引而不是整数位置计算持有周期;或对停牌股单独剔除 |
| 样本重叠 | 同一只股票连续多日命中,这些「笔」的收益高度相关,不是独立样本 | 按 (symbol, 日期) 去重后再统计一次样本量与胜率 | 用去重后的样本给结论;连续命中视为一个持仓周期而不是多笔交易 |
| 复权口径混用 | 库内是后复权价,收益里隐含「分红全部再投资」的假设,与实盘现金流不同 | 抽一只高分红股票,对比后复权收益率与实盘实际到手的收益率 | 结论里注明「后复权口径、分红再投资假设」,不要直接等同于账户收益 |
| 成本与滑点被忽略 | 项目不含费率模型,容易顺手就按 0 成本跑 | 把成本从 0 改成「佣金+印花税+0.1% 滑点」再跑一遍,看收益缩水幅度 | 高频信号策略尤其要加成本;缩水后仍成立的结论才有参考价值 |
| 把命中数当成策略质量 | 放宽阈值 → 命中变多 → 看起来「更活跃」,实际只是噪声增加 | 同时看命中数与后续收益中位数,两者应同向变好 | 用「命中数 + 收益分布 + 回撤」三件套评估,单一指标不作为依据 |
shift),再修股票池(幸存者偏差),最后加成本。前两项修完结论可能直接反转,此时再谈成本才有意义。样本外与参数拟合要守哪三条纪律?
阈值都是策略类里的常量,改一个数字就能让历史好看——这正是过拟合的入口。下面三条纪律能把这个风险压到最低。
先把区间切成两段,再动手改参数
# 例:库内 2024-01-01 起,按时间切成 70% / 30% cut = "2025-06-30" train = bars[bars["date"] <= cut] # 样本内:可以反复试参数 test = bars[bars["date"] > cut] # 样本外:只能跑一次 # 纪律:参数只在 train 上挑,选定后到 test 上只跑一次并如实记录最容易违反的一点是「在样本外跑完不满意又回去调参」——那等于把样本外也用成了样本内。要改就重新切一段新的样本外区间。
记录每一次参数改动,连同改动的理由
# 建议在策略类里保留一份改动记录,而不是只留最终值 class MyStrategy(BaseStrategy): webhook_key = "my_signal" window = 20 # 2026-09-20 由 15 调整为 20:15 在样本内命中过多,改用月线级别 threshold = 1.5 # 保持默认,未调没有理由的调参最后只剩一堆数字。半年后回看,你无法判断某个阈值是「有依据」还是「试出来的」。
用样本外结果说话,并公布失败的那部分
print(res.groupby("hold")["ret"].agg(["count", "mean", "median"])) # 同时输出:去重后的样本量、无法成交样本占比、分年度收益 # 结论里必须包含「样本外表现」与「未通过的假设」本站对任何策略都不给有效性结论,原因就在这里:形态类规则的有效性会随市场环境变化,样本外表现才是决定它能不能用的依据。所有结果仅供研究与学习,不构成投资建议。
策略验证模板:怎么先把口径固定下来?
写策略容易,证明策略有用很难。下面的模板把「验证一次策略」需要固定的十二项全部列出来——先照这张表填完,再决定要不要相信任何结论。
| 口径项 | 为什么必须写清 | 本站的取值示例(本轮实测) |
|---|---|---|
| 项目版本 / commit | 规则可能随时改,没有版本号的结论无法复核 | 444c0db(2026-05-09,master HEAD) |
| 回测区间 | 形态有效性随市场环境变化 | 库内 668 个交易日:2024-01-02 ~ 2026-10-09 |
| 股票池是否含退市股 | 不含退市股会天然乐观(幸存者偏差) | 不含:status=1 且 type=1 过滤后 5224 只,退市股不在库内 |
| 复权口径 | 影响收益率与形态比例 | 库内为后复权(adjustflag=1);成交价类计算需另取不复权 |
| 信号日定义 | 决定「什么时候算触发」 | K 线最后一根(df.iloc[-1]),需收盘后才成立 |
| 成交日与成交价 | 不能用信号日收盘价成交 | 三选一并写明:次日开盘 / 次日均价 / 次日收盘 |
| 买不到的样本怎么算 | 一字板、停牌会让回测虚高 | 显式剔除或按「顺延到下一可成交日」处理,并统计占比 |
| 交易成本 | 小额样本里成本能吃掉全部差异 | 佣金(双边)+ 印花税(卖出)+ 你估计的滑点,逐项写明数值 |
| 持有期 | 同一信号在不同持有期结论可能相反 | 至少给 1 / 5 / 10 / 20 个交易日四档 |
| 基准 | 没有基准就分不清是策略有效还是大盘好 | 沪深300 或中证全指;库内没有指数数据,需另取 |
| 样本量 / 分布 | 样本太少时任何统计量都不稳定 | 写清信号总数、涉及标的数、按日期分布(是否集中在少数几天) |
| 样本内 / 样本外 | 参数是在全样本上调出来的,必须留出验证段 | 例如 2024-01 ~ 2025-12 定参数、2026-01 之后只验证 |
先把信号与成交拆成两列,再谈收益
-- 每个信号日,取「信号日收盘」与「下一个有数据的交易日的开盘/收盘」 WITH seq AS ( SELECT symbol, date, close, LEAD(date) OVER (PARTITION BY symbol ORDER BY date) AS exec_date, LEAD(open) OVER (PARTITION BY symbol ORDER BY date) AS exec_open, LEAD(close) OVER (PARTITION BY symbol ORDER BY date) AS exec_close FROM stock_daily) SELECT * FROM seq WHERE exec_date IS NOT NULL;这张表是后面一切统计的地基。注意
exec_date用LEAD取「下一根有数据的 K 线」,不是date + 1——停牌与长假会让自然日口径失真(本站实测 21.3% 的相邻交易日间隔 ≥ 3 天)。三条会让结论作废的做法(写报告前自查)
做法 为什么作废 正确做法 用信号日收盘价当成交价 收盘价与信号同时产生,属未来函数 用 exec_open/ 次日均价 /exec_close,并写明三选一在全样本上调阈值再报全样本收益 参数拟合进样本,样本外大概率失效 切出样本外区间,只报样本外读数 用「现在的股票池」回溯历史 退市股不在池内,长期结论必然偏乐观 声明股票池口径,或补入已退市样本 本站明确不做的事
本站不发布任何收益率、胜率、年化、回撤数字。原因不是保守,而是:本项目没有回测引擎(仓库无 backtest 模块)、没有官方绩效数据、本站本轮也没有做样本外验证。用三五个样本算出「某个数字」并不难,难的是让那个数字经得起上面十二项口径的检查——本轮只把检查清单交给你。
二次开发常见问题
给 Sequoia-X 加策略必须继承 BaseStrategy 吗?
如果在 main.py 的策略列表里使用,是的——列表元素类型标注为 BaseStrategy,而基类用抽象方法强制子类实现 run()。若只是想临时算一下,也可以直接写脚本调用数据引擎,但那样不会进入每日流水线。
Sequoia-X 新策略怎么推到自己的群?
给类设一个 webhook_key(如 "my_signal"),然后在 .env 里加 STRATEGY_WEBHOOK_MY_SIGNAL。键名不匹配时会回落到 FEISHU_WEBHOOK_URL,也就是说「配错了也能跑,但会进错群」。
Sequoia-X 能用分钟线或财务数据写策略吗?
不能直接支持。本地库只存日 K(后复权),表里也没有财务字段;要用其他数据需要自己扩展取数与存储,那已经超出「加一个策略类」的范围。数据边界见数据层页。
Sequoia-X 策略执行顺序会影响结果吗?
不影响各自的判定结果——每个策略独立读库、独立返回清单,彼此不共享状态。顺序只决定日志打印与推送的先后。反过来说,同一只股票可能同时被多个策略选中,推送里会出现多条。
怎么判断 Sequoia-X 策略是不是太慢?
看日志里策略行之间的时间差。逐只读库的方式耗时与标的数成正比,全表读取则受内存与分组计算影响;如果某个策略明显拖慢整体,优先检查是否误用了逐行遍历。
改了源码要重装吗?
用 pip install . 安装的话,源码改动需要重新安装或使用可编辑安装(pip install -e .)才能生效;用 uv sync 管理环境时同理。具体以官方 README 与你的环境管理方式为准。
回测收益多少算好?
本站不给阈值,因为「多少算好」取决于你的持有周期、成本假设与基准。可操作的标准是三条:①收益中位数是否为正,而不是只看平均值;②加入佣金、印花税与滑点后是否仍然为正;③样本外区间是否与样本内方向一致。三条都过才有讨论价值,任何一条不过都应视为未验证。所有结果仅供研究,不构成投资建议,以你自己的实测数据为准。
怎么确认我的回测没有未来函数?
两个自检:①把买入价从「信号日收盘价」改成「次日开盘价」(open.iloc[i+1]),若收益大幅下滑,说明原结果依赖了当日收盘这个未来信息;②检查所有滚动窗口是否都先 shift(1) 再 rolling——本项目海龟突破的 high.shift(1).rolling(20).max() 就是标准写法。具体口径以官方源码为准。