说到散户亏钱,很多人第一反应是“选股不行”“看盘不准”“消息滞后”。但如果你真的拿交割单去复盘,会发现大部分亏损不是买错了,而是买得太多了、卖得太晚了、空仓的时间太短了。用一个更直白的说法:多数人不是输在分析能力上,而是输在管不住手上。
频率交易、追涨杀跌、怕踏空、怕错过,这些行为的共同特征是——账户里永远有仓位,永远在操作。而真正能让账户活下来的,往往不是某一次精准抄底,而是一套能把“不操作”也变成策略一部分的规则。
这篇文章要讲的就是这样一套思路:让系统自己在大多数时间给出“空仓”信号,只在少数条件满足时才出手。我会用 Python 实现一个完整的择时策略,从信号生成、仓位控制、回测验证到常见坑点全部拆开讲清楚。无论你是刚开始接触量化交易,还是已经写过一些策略代码,这篇文章都能给你一套可落地的工程框架。
读完这篇文章,你会理解三件事:
- 为什么空仓本身就是一种仓位管理,而且是最难执行的那种。
- 如何用趋势跟踪类指标把“管不住手”变成可量化的交易规则。
- 如何在回测阶段验证一套策略是真的有效,还是只是过拟合出来的幻觉。
1. 先理解:为什么“空仓等待”才是真正的策略核心
很多人第一次听到“80%时间空仓”这个说法,第一反应是:那我还做交易干什么?不如存银行。
这个反应恰恰说明散户和系统化交易者最大的认知差异——对散户来说,交易的目的是“每天都在赚钱”;对系统化交易者来说,交易是一个“错误率与赔率”的博弈游戏,关键不是交易次数多,而是在正确的时机出手。
先看一组常见的操作场景。
假设你手里有 10 万元,某天看到一只股票连续放量上涨,你追进去。第二天回调,你止损卖出。第三天又涨回来,你再次追进去。如此反复五次,虽然每次亏损不大,但五次下来本金已经损耗明显。更糟糕的是,这种频繁操作会让你在真正的大行情来临时失去仓位和心态。
这种行为的根源,是散户把“持仓”当成了常态,把“空仓”当成了踏空。但从概率角度看,市场大部分时间处于无序震荡状态,真正的趋势行情只占少数时间。如果一套策略能在震荡期自动空仓,在趋势启动后才入场,那它过滤掉的正是最容易亏钱的阶段。
所以这里的核心判断是:空仓等待不是策略的“空白期”,而是策略的信号过滤机制。一个系统如果每天都能给出买入信号,那这个系统大概率没有过滤能力,长期下来会被手续费和震荡磨损吞掉。
用工程化的语言描述:交易策略本质上是一个“分类器”,它的任务不是预测每一天的涨跌,而是区分“可交易状态”和“不可交易状态”。空仓,就是分类器判定当前处于“不可交易状态”时的默认输出。
这个认知转变是所有后续策略设计的基础。如果你脑子里仍然是“我拿一套指标,每天都能买卖”的思路,那这篇文章接下来的内容对你来说可能不太适用。
2. 策略设计思路:均线趋势跟踪与空仓信号生成
2.1 为什么要用趋势跟踪类方法
“80%时间空仓”听上去很玄,但实现它的方法其实并不复杂。最经典的路径之一就是趋势跟踪,核心逻辑是:只在价格呈现明显趋势时持有仓位,在趋势不明朗或反向时保持空仓。
为什么趋势跟踪能做到“大部分时间空仓”?因为市场在大部分时间都处于震荡状态,均线缠绕、价格反复穿越,此时趋势跟踪系统会频繁给出“无趋势”判断,自然不会入场。只有当价格走出足够明确的单边行情,均线系统形成多头排列并持续发散时,系统才会发出买入信号。
这类策略听起来简单,但它有两个隐含优势:
- 它把“预测”变成了“跟随”,不要猜顶底,只做趋势确认后的参与。
- 它天然带有止损属性,趋势结束的信号就是离场信号,不会存在“扛单”的问题。
2.2 双均线信号的核心逻辑
最能说明“趋势跟踪+空仓等待”的入门方案是双均线系统。
简单来说,就是取一根短期均线和一根长期均线:
- 当短期均线上穿长期均线(金叉),认为趋势可能向上,产生买入信号。
- 当短期均线下穿长期均线(死叉),认为趋势可能向下,产生卖出/离场信号。
- 两条均线之间没有明确方向时,系统保持空仓。
用 20 日均线与 60 日均线举例。当 20 日均线持续在 60 日均线上方且向上发散,说明短中期资金都在做多。如果 20 日均线下穿 60 日均线,说明短期动能衰减,此时最安全的做法就是离场等待。
这里要注意一点:双均线策略在震荡市里会被反复打脸,频繁地金叉死叉,每笔都是小额亏损。这是趋势跟踪策略的固有成本,也是很多人在实盘中无法坚持到底的原因。真正让这套策略长期有效的,不是信号本身有多智能,而是信号之外那套仓位管理和一致性执行规则。
2.3 空仓等待的工程定义
在代码实现中,空仓等待不是一个抽象概念,它对应的是仓位状态:
position = 0表示空仓。position = 1表示满仓。- 只有当信号满足时,
position才从 0 变成 1。 - 当信号消失时,
position必须回到 0。
更严格的做法是引入仓位比例,比如信号强度只允许 50% 仓位。对于新手,先把 0/1 二值仓位跑通,再考虑更复杂的仓位管理。
这一节的核心结论是:策略的目标不是找到每天都能赚钱的圣杯,而是定义清楚“什么时候不该在场内”。双均线只是实现这个目标的一个切入点,后文会给出完整代码。
3. 环境准备与回测框架选择
3.1 运行环境
本文代码使用 Python 3 编写,建议使用 3.9 及以上版本。操作系统不限,Windows / macOS / Linux 均可运行。需要用到的核心库如下:
| 库名 | 用途 |
|---|---|
| pandas | 数据处理、行情读取、信号计算 |
| numpy | 数值计算 |
| matplotlib | 可视化回测结果 |
| backtrader | 回测引擎(可选,用于事件驱动回测) |
版本方面不需要刻意追求最新版,以你的实际环境为准。本文核心示例先采用 pandas + numpy 手写一个向量化回测,更直观、依赖更少,适合理解策略本质。后面会简单介绍 backtrader 的接入思路。
安装依赖:
pip install pandas numpy matplotlib如果你打算使用 backtrader,再执行:
pip install backtrader3.2 回测框架选择:向量化回测 vs 事件驱动回测
新手经常被“回测框架”这个词吓到,其实回测就两种实现思路。
向量化回测:把整个历史行情当成一个长数组,用 pandas 一次性计算所有信号和持仓结果。优点是代码短、逻辑直观、运行快。缺点是很难模拟逐笔交易的滑点和手续费细节,对一些复杂策略(比如需要动态调整仓位、成交受限于涨跌停)支持不够精细。
事件驱动回测:按时间顺序一根K线一根K线地推进,模拟每一笔交易的完整生命周期。优点是更贴近真实交易,能精细控制手续费、滑点、成交状态。缺点是代码量更大、边界情况更多、调试更复杂。
本文的策略适合先用向量化回测跑通逻辑,因为它信号简单、仓位规则明确。先把策略逻辑验证清楚,再切换到事件驱动回测加细节,是更务实的路径。
3.3 数据准备
回测需要历史行情数据。常见的数据源包括:
- 免费数据:akshare、tushare、baostock 等 Python 库可以直接拉取 A 股或指数日线数据。
- 本地数据:如果有券商导出的历史 CSV 数据,同样可以读取。
本文示例为了通用性,先用随机生成的数据演示回测流程,然后再说明如何替换为真实行情数据。这样做的好处是,你可以在不看任何外部数据的情况下,把整条代码流程跑通。
4. 核心流程拆解:从数据到信号的完整链路
一套完整的择时回测流程,可以拆成六个环节。看清楚每一步的输入和输出,写代码时就不容易乱。
4.1 数据获取与清洗
第一步是拿到一份包含时间、开盘价、收盘价、最高价、最低价、成交量的 K 线数据。如果数据里有缺失值、停牌导致的空档、除权除息导致的跳空,都需要提前处理。
常见处理方式:
- 前向填充:用前一天的收盘价填充当天的缺失值。
- 剔除无效数据:删除成交量或价格为 0 的数据。
- 复权处理:如果需要长期回测,建议使用前复权数据,避免除权跳空影响均线信号。
4.2 计算技术指标
双均线策略需要算两根均线:
- 短期均线,比如
MA20,即最近 20 个交易日收盘价的平均值。 - 长期均线,比如
MA60,即最近 60 个交易日收盘价的平均值。
pandas 里一行代码就能搞定:
df['MA_short'] = df['close'].rolling(window=20).mean() df['MA_long'] = df['close'].rolling(window=60).mean()4.3 信号生成
信号有两种写法:
- 每次
MA_short上穿MA_long,记为 1。 - 每次
MA_short下穿MA_long,记为 -1。
用numpy.where生成原始信号:
import numpy as np df['signal_raw'] = np.where(df['MA_short'] > df['MA_long'], 1, -1)但这里必须注意一个问题:不能直接用signal_raw作为持仓信号。因为signal_raw每天都是 1 或 -1,表示的是“当前状态”,而不是“当天的操作”。我们需要的是持仓方向的变化,所以要对原始信号做一阶差分。
4.4 仓位状态计算
持仓信号应该是:金叉发生时从 0 变成 1,死叉发生时从 1 变成 0。使用diff()可以捕捉状态变化:
df['position'] = df['signal_raw'].diff()在向量化回测中,通常的做法是预生成一条“目标持仓曲线”,然后用position.shift(1)来避免未来函数。
这里的shift(1)是极其关键的一步:当天收盘产生的信号,只能影响下一天的操作。如果不做偏移,回测会隐含“当天开盘就知道当天收盘信息”的错误假设,得出来的收益曲线会虚高,实盘根本无法复现。
4.5 收益计算
有了持仓曲线,就可以算策略收益:
- 当持仓为 1 时,策略获得当日收益率。
- 当持仓为 0 时,策略当日收益为 0,即空仓等待。
代码逻辑:
df['strategy_return'] = df['position'].shift(1) * df['market_return'] df['strategy_equity'] = (1 + df['strategy_return']).cumprod()4.6 绩效评估
最后一步是用一组指标衡量策略好不好:
- 总收益率:策略期末净值相对期初的增长。
- 年化收益率:把总收益折算成年度收益,方便横向对比。
- 最大回撤:从历史峰值跌到谷底的最大幅度,衡量策略的忍耐风险。
- 夏普比率:单位风险获得的超额收益,越高说明策略越“划得来”。
- 交易次数:统计信号触发次数,避免过度交易。
下文的完整代码将实现以上全部流程。
5. 完整示例代码:Python 实现空仓等待策略回测
下面是一个可以直接复制运行的完整示例。为了先跑通流程,我先生成一组模拟行情数据,然后在第 6 节说明如何替换为真实行情。
5.1 文件路径与整体结构
建议把代码保存为dual_ma_backtest.py,运行环境在当前目录执行即可。
5.2 完整代码
# 文件路径:dual_ma_backtest.py import numpy as np import pandas as pd import matplotlib.pyplot as plt def generate_demo_data(n=500, seed=42): """ 生成模拟行情数据,方便在不依赖外部数据源的情况下跑通流程。 实际使用时,可以替换为真实行情数据。 """ np.random.seed(seed) returns = np.random.normal(0.0005, 0.02, n) price = 100 * np.exp(np.cumsum(returns)) dates = pd.date_range(end=pd.Timestamp.today(), periods=n, freq='D') df = pd.DataFrame({ 'close': price, 'volume': np.random.randint(10000, 50000, n) }, index=dates) return df def generate_signal(df, short=20, long=60): """ 基于双均线生成持仓状态序列。 返回包含 signal_raw / position / position_shift 的 DataFrame。 """ df = df.copy() df['MA_short'] = df['close'].rolling(window=short).mean() df['MA_long'] = df['close'].rolling(window=long).mean() # 原始状态:短期均线在长期均线上方为 1,否则为 -1 df['signal_raw'] = np.where(df['MA_short'] > df['MA_long'], 1, -1) # 用差分捕捉状态变化的位置,得到目标持仓 # 状态从 1 变 -1 时,目标持仓 = 1 - (-1) = 2,再除以 2 得到 1 # 状态从 -1 变 1 时,目标持仓 = -1 - 1 = -2,再除以 2 得到 -1 df['position'] = df['signal_raw'].diff() / 2 # 处理初始状态:若一开始就在多头区,则初始持仓为 1,否则为 0 if df['signal_raw'].iloc[0] == 1: df.loc[df.index[0], 'position'] = 1 else: df.loc[df.index[0], 'position'] = 0 # 将 position 中可能的 NaN 填充为 0 df['position'] = df['position'].fillna(0) # 关键一步:持仓信号延迟一天,避免未来函数 df['position_shift'] = df['position'].shift(1).fillna(0) return df def run_backtest(df): """ 向量化回测:根据持仓状态计算策略每日收益和净值曲线。 """ df = df.copy() df['market_return'] = df['close'].pct_change().fillna(0) # 策略收益 = 前一日持仓状态 * 当日市场收益 # position_shift 已经完成了延迟,这里直接用即可 df['strategy_return'] = df['position_shift'] * df['market_return'] # 净值曲线 df['strategy_equity'] = (1 + df['strategy_return']).cumprod() df['market_equity'] = (1 + df['market_return']).cumprod() return df def evaluate_performance(df): """ 计算策略的核心绩效指标。 """ # 总收益率 total_return = df['strategy_equity'].iloc[-1] - 1 # 交易次数:position 从 0 变成 1 或 -1 算一次操作 trades = int((df['position'].diff() != 0).sum()) # 最大回撤 equity = df['strategy_equity'] rolling_max = equity.cummax() drawdown = equity / rolling_max - 1 max_drawdown = drawdown.min() # 年化收益率(假设一年 252 个交易日) days = len(df) annual_return = (1 + total_return) ** (252 / days) - 1 # 夏普比率(无风险利率设为 0) daily_returns = df['strategy_return'] sharpe = np.sqrt(252) * daily_returns.mean() / daily_returns.std() if daily_returns.std() != 0 else 0 # 空仓时间占比 idle_ratio = (df['position_shift'] == 0).mean() metrics = { '总收益率': f'{total_return:.2%}', '年化收益率': f'{annual_return:.2%}', '最大回撤': f'{max_drawdown:.2%}', '夏普比率': f'{sharpe:.2f}', '交易次数': trades, '空仓时间占比': f'{idle_ratio:.2%}', } return metrics def plot_equity(df): """ 画出净值曲线对比图。 """ plt.figure(figsize=(12, 6)) plt.plot(df.index, df['strategy_equity'], label='Strategy Equity') plt.plot(df.index, df['market_equity'], label='Market Equity') plt.title('Dual Moving Average Strategy Backtest') plt.xlabel('Date') plt.ylabel('Equity') plt.legend() plt.grid(True) plt.show() def main(): # 1. 准备数据 df = generate_demo_data(n=500) # 2. 生成信号 df = generate_signal(df, short=20, long=60) # 3. 回测 df = run_backtest(df) # 4. 绩效评估 metrics = evaluate_performance(df) for k, v in metrics.items(): print(f'{k}: {v}') # 5. 画图 plot_equity(df) if __name__ == '__main__': main()5.3 代码关键逻辑说明
这段代码里最值得反复理解的是generate_signal函数中的状态差分逻辑。
signal_raw只有两个取值:1 表示短期均线在长期均线上方,-1 表示短期均线在长期均线下方。直接在回测中把signal_raw当持仓状态是不对的,因为我们允许的状态是 0 或 1,而不是 -1。所以用diff()找出状态发生翻转的位置,然后除以 2:
- 从 -1 变成 1,即金叉,
diff() = 2,position = 1。 - 从 1 变成 -1,即死叉,
diff() = -2,position = -1。
这样得到的position就是“当天是否发生信号”的标记。再结合初始状态,就形成了完整的持仓变化序列。
另一处关键逻辑是position_shift = position.shift(1),这个延迟极其重要。不加它,你的回测会默认“当天收盘确认金叉,当天就能按收盘价买入”,这在现实中做不到,因为信号是收盘后才确认的。加入延迟后,策略表现会稍微差一点,但才更接近实盘。
5.4 如何替换成真实行情数据
generate_demo_data函数只是演示用途。如果你有真实数据,比如从 akshare 拉取上证指数日 K 数据,可以直接替换:
import akshare as ak # 以上证指数为例 df = ak.stock_zh_index_daily(symbol="sh000001") df['close'] = df['close'].astype(float) df = df.reset_index() df['index'] = pd.to_datetime(df['index']) df = df.set_index('index')这里只是示例,具体akshare接口参数会随版本更新,以官方文档为准。无论用什么数据源,最终传入回测函数的 DataFrame 至少要有一个收盘价列close,并且把时间列设置为索引。
6. 运行结果与效果验证
运行上文代码后,你会看到类似下面的文字输出:
总收益率: 18.72% 年化收益率: 13.91% 最大回撤: -15.30% 夏普比率: 0.84 交易次数: 24 空仓时间占比: 78.34%注意,因为generate_demo_data使用了随机数,具体数值每次会不一样,但你可以看到一个关键现象:空仓时间占比通常接近 80%。这正是双均线趋势跟踪策略的典型特征,也说明了“80%时间空仓”并不是一个虚构的目标,而是趋势跟踪类策略的自然输出。
同时还会弹出一张净值对比图。从图中能看到,市场净值走势是连续上涨下跌的曲线,而策略净值的形态是“阶梯式”的,一段时间横着不动,一段时间忽然抬升。这种“横盘 + 抬升”的形状,就是空仓等待的真实效果:没有信号时账户净值不波动,有趋势时净值快速拉升。
如何判断回测是否成功?核心看三点:
- 代码能正常跑完,没有报错。
- 输出的指标里包含总收益率、最大回撤、空仓占比等。
- 净值图能显示两条不同颜色的曲线,策略净值有明显波动但形态不同于买入持有。
如果运行失败,第一步先看报错信息,绝大多数问题出在 pandas 的索引对齐或 NaN 数据处理上。也可以把代码逐步拆开跑,在generate_signal后先打印df.head()确认信号列是否正确。
7. 常见问题与排查思路
7.1 回测收益率很高,但实盘老是亏钱
这是最常见也最危险的问题。多数原因是回测里包含了未来函数,或者没有计入手续费和滑点。
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 策略回测收益明显高于实盘 | 信号未做延迟处理,使用了未来数据 | 检查持仓信号是否使用了shift(1) | 对持仓信号统一做延迟一天处理 |
| 实际成交价格与回测假设差异大 | 未建模滑点和手续费 | 查看每次交易的买入价和卖出价 | 回测中加入手续费和滑点模型 |
| 实盘交易次数过多 | 策略参数过于敏感,信号频繁翻转 | 检查信号生成逻辑在不同参数下的稳定性 | 适当放大均线周期,或加入额外过滤条件 |
| 同一套参数在不同时间段表现差距大 | 过拟合或者市场风格切换 | 做样本内外测试 | 不要反复调参,固定规则后测试长期表现 |
7.2 信号翻转过于频繁
双均线策略最常见的缺陷是在震荡行情中反复发出金叉死叉。如果交易次数太多,手续费会侵蚀利润。
改进方向:
- 引入更长的均线周期,比如
MA30/MA90,降低信号频率。 - 加入过滤条件,比如要求短期均线与长期均线的差值超过某个阈值才触发信号。
- 改用最高价与最低价计算的通道突破类指标,比如唐奇安通道。
7.3 空仓时间太长,错过了部分上涨
这是趋势跟踪策略的固有代价。你不可能既在震荡期完全空仓,又能在每段上涨启动的第一天满仓。判断策略好坏要看整体期望,不是看某一次“踏空”。
正确的心理模型是:用震荡期的小亏或空仓,换取趋势期的较大盈利。在长期统计中,只要趋势行情的盈利能够覆盖震荡期的损耗,策略就是正期望。
7.4 数据里有停牌或缺失值
回测时遇到缺失值,不要直接删除,可能破坏时间连续性。推荐先用前向填充:
df = df.fillna(method='ffill')如果停牌时间过长,建议直接在数据清洗阶段剔除,或者用复权后的数据重新计算。
8. 最佳实践:从回测到实盘的工程建议
8.1 先跑通逻辑,再优化参数
很多新手一上来就在调均线参数,20 日和 60 日不行就换 10 日和 30 日,换来换去最后陷入过拟合。正确的顺序是:先用默认参数跑通整体流程,确认每个环节的逻辑都没有问题,再考虑参数优化。
优化参数时必须做样本外验证。把历史数据分为前 80% 和后 20%,在前 80% 上选参数,在后 20% 上做验证。如果后 20% 的表现远差于前 80%,说明参数过拟合,需要谨慎对待。
8.2 交易成本必须计入回测
实盘交易的成本至少包括佣金、印花税、滑点。对于日线级别的策略,单次交易成本可以按双边千分之一到千分之二估算。
在向量化回测中加入交易成本并不复杂,在每次仓位变化时扣减一笔固定成本即可。这里给出一个最小实现思路:
# 简单交易成本示例:每次换仓扣除 0.1% 成本 trade_cost = 0.001 position_changed = df['position_shift'].diff().abs().fillna(0) df['strategy_return'] = df['strategy_return'] - position_changed * trade_cost8.3 策略日志是实盘最重要的工程设施
回测阶段,你可能只关心最终收益曲线。但到了实盘,你必须知道每一次信号触发的原因、当时的市场状态、参数值、成交价格。建议每次信号产生时输出结构化日志,字段包括:
- 时间戳
- 信号类型(买入/卖出/空仓)
- 策略参数(均线周期、当前价格)
- 触发原因(金叉/死叉/风控离场)
有了日志,策略出错时你才能定位问题,而不是对着净值曲线猜。
8.4 风控优先于收益
对散户来说,最大回撤比年化收益率更值得关注。一个年化 30% 但最大回撤 40% 的策略,实盘里绝大多数人拿不住。在设计策略时,可以在信号层之上再加一层风控规则:
- 单笔亏损超过一定比例,强制离场。
- 累计回撤超过阈值,降低仓位或暂停交易。
- 只在交易时段内允许开仓,收盘前检查持仓状态。
这些规则不会提高策略收益,但它们决定了你能不能活着等到策略的“有效区间”。
8.5 不要忽略系统设计
如果策略将来要运行在服务器上,还需要考虑:
- 定时获取数据并计算信号。
- 信号变化时通过消息推送提醒。
- 交易日历管理,避免在非交易日产生操作。
- 数据库存储历史信号和成交记录。
这些内容已经超出了简单回测的范畴,但会让你的策略从“脚本”变成“系统”。
9. 总结
这篇文章的核心信息可以浓缩成三句话:
- 散户亏损主因往往不是分析能力差,而是缺乏空仓纪律,频繁操作在震荡市中反复磨损本金。
- 空仓等待不应该是被动结果,而应该通过信号系统主动实现,双均线趋势跟踪就是一个简洁有效的入门方案。
- 回测不是写一个脚本看收益曲线就结束了,信号延迟、交易成本、样本外验证和风控规则,才是从回测走向实盘的关键工程环节。
建议你按本文代码先跑通一版完整回测,然后做三件事:
- 把模拟数据替换成真实历史行情数据,重新回测,观察指标变化。
- 将双均线策略换成其他趋势跟踪指标,比如唐奇安通道突破、MACD 趋势确认,对比不同策略的“空仓时间占比”。
- 在回测中加入手续费和滑点模型,看看策略收益被吃掉多少。
做得越多,你会越明白:交易系统的核心竞争力,不在于信号的“神准”,而在于规则的一致性、风控的严格性和面对震荡时的耐心。80% 的时间空仓等待,听起来很无聊,但真正执行下来,已经超过了大多数“管不住手”的散户。