【Python量化趋势跟踪实战 01】均线多头排列就是趋势开始吗:过滤与确认
金叉买、死叉卖——这六个字几乎人人听过,但实盘里照做的人多半亏得怀疑人生。为什么?因为震荡市里均线会反复缠绕,今天金叉明天死叉,来回打脸。
这篇从一个最简单的改进开始:在金叉后面再加一道过滤条件,看看能不能把假信号压下去。
本文你将得到什么
- 一个最基础的均线交叉策略(SMA20/60)及其回测框架。
- 一道「趋势确认」过滤逻辑:不仅要看短期均线上穿长期均线,还要看短期均线本身是否在往上走。
- 两个策略的诚实对比:过滤后的信号未必更多,但质量可能更高。
- 可直接复制的 Python 代码,次日开盘执行、无前视偏差。
一、裸金叉的问题:震荡市反复打脸
均线交叉的本质是「滞后确认」。当价格已经从低点涨了一段,短期均线才追上长期均线,发出金叉。这个滞后本身不是bug——趋势跟踪本来就要牺牲前段涨幅来换取确定性——但麻烦在于,如果价格只是在区间里上下波动,两条均线也会跟着缠绕,产生大量没有趋势支撑的信号。
下面的代码先把「裸金叉」跑一遍,看看它长什么样。
可运行代码
pip install mairui pandas numpy
from mairui import Client
import pandas as pd
import numpy as np
import random
LICENCE = "LICENCE-66D8-9F96-0C7F0FBCD073" # 演示证书,请换成你自己的正式证书
client = Client(LICENCE)
SEED = 20261015
def synth_ohlc(bars, seed):
# 演示证书返回固定样例数据;用确定性合成路径模拟行情起伏(慢变波动率+波动聚集)。
# 换成你自己的正式证书后删掉本函数调用即可,其余逻辑零改动。
rng = random.Random(seed)
g = 0.0
out, prev_c = [], None
base_date = pd.to_datetime(bars[0]["t"])
for i, b in enumerate(bars):
g = 0.9 * g + rng.gauss(0, 0.25)
sigma = 0.015 * float(np.exp(g))
if prev_c is None:
c = float(b["c"])
o = c * (1 + rng.gauss(0, 0.3 * sigma))
else:
c = prev_c * (1 + rng.gauss(0, sigma))
o = prev_c * (1 + rng.gauss(0, 0.3 * sigma))
h = max(o, c) * (1 + abs(rng.gauss(0, 0.5 * sigma)))
l = min(o, c) * (1 - abs(rng.gauss(0, 0.5 * sigma)))
date_str = (base_date + pd.Timedelta(days=i)).strftime("%Y-%m-%d")
out.append({"t": date_str, "o": round(o, 2), "h": round(h, 2), "l": round(l, 2), "c": round(c, 2)})
prev_c = c
return out
def fetch_and_extend(code, target_bars=200):
raw = client.stock_history(code, period="d", dividend="f")
extended = synth_ohlc(raw * ((target_bars // len(raw)) + 1), SEED + int(code))
return extended[:target_bars]
def backtest(df, signal_col, label):
df = df.copy()
df["next_o"] = df["o"].shift(-1)
df["position"] = df[signal_col].shift(1).fillna(0)
df["return"] = df["position"] * (df["next_o"] - df["o"]) / df["o"]
df["cum"] = (1 + df["return"].fillna(0)).cumprod() - 1
nav = (1 + df["return"].fillna(0)).cumprod()
dd = (nav / nav.cummax() - 1)
trades = df["position"].diff().abs().sum()
return {
"label": label,
"total_return": f"{df['cum'].iloc[-2]*100:.2f}%",
"max_dd": f"{dd.min()*100:.2f}%",
"trades": int(trades),
"win_rate": f"{(df['return'] > 0).sum() / (df['return'] != 0).sum() * 100:.1f}%" if (df['return'] != 0).sum() > 0 else "N/A",
}
# 拉数
data = fetch_and_extend("600519", target_bars=200)
df = pd.DataFrame(data)
df["t"] = pd.to_datetime(df["t"])
df = df.sort_values("t").reset_index(drop=True)
# 计算均线
df["sma20"] = df["c"].rolling(20).mean()
df["sma60"] = df["c"].rolling(60).mean()
# 策略 A:裸金叉(SMA20 > SMA60 即持仓)
df["sig_raw"] = (df["sma20"] > df["sma60"]).astype(int)
# 策略 B:趋势确认过滤(SMA20 > SMA60 且 SMA20 本身在上升)
df["sma20_rising"] = (df["sma20"] > df["sma20"].shift(3)).astype(int)
df["sig_align"] = ((df["sma20"] > df["sma60"]) & (df["sma20_rising"] == 1)).astype(int)
# 切掉热身期
df_eval = df.iloc[60:].copy().reset_index(drop=True)
rA = backtest(df_eval, "sig_raw", "裸金叉")
rB = backtest(df_eval, "sig_align", "趋势确认")
print("策略 A — 裸金叉")
for k, v in rA.items():
if k != "label":
print(f" {k}: {v}")
print("\n策略 B — 趋势确认")
for k, v in rB.items():
if k != "label":
print(f" {k}: {v}")
运行结果
策略 A — 裸金叉
total_return: 50.88%
max_dd: -11.04%
trades: 3
win_rate: 53.5%
策略 B — 趋势确认
total_return: 55.14%
max_dd: -7.27%
trades: 5
win_rate: 56.2%
二、结果解读:过滤到底起了什么作用
这个样本里,策略 B 的收益略高于策略 A(55.14% vs 50.88%),但更关键的是回撤从 -11.04% 收窄到 -7.27%。这意味着过滤条件在趋势不明确的时候把仓位压了下来,避开了部分无效波动。
注意两点:
- 交易次数没减少:策略 B 反而多了 2 笔交易。因为「SMA20 正在上升」这个条件比单纯的「SMA20 > SMA60」更敏感——它可以在价格刚启动、还未大幅偏离均线时就确认趋势。代价是可能多踩几次假启动,但样本期内总体仍更优。
- 不保证每次都赢:这只是一个 200 日的合成样本。换一段行情,过滤条件也可能让你错过一波急涨。过滤的本质是用牺牲部分机会来换取更低的回撤。
三、常见坑
- 前视偏差:信号必须基于当日收盘数据计算,次日开盘才能执行。代码里用
signal.shift(1)保证这一点,不要在收盘前偷跑。 - 均线周期迷信:20/60 只是示例。周期越短信号越多、噪音越大;周期越长信号越少、滞后越严重。没有 universally optimal 的参数。
- 演示证书数据有限:本例用合成路径扩展了数据量,正式证书可拉全量真实历史做更充分的回测。
小结与下篇预告
今天我们验证了:单纯的金叉不够,加一道「短期均线本身在上升」的确认过滤,可以在样本期内降低回撤、提升风险收益比。
但光会判断趋势还不够——入场之后,止损放哪? 固定百分比止损在趋势策略里往往太早被震出。下篇我们引入 ATR 动态止损,让盈利仓位「扛得住正常回调」。
免责声明:本文仅供技术学习交流,不构成任何投资建议。量化策略回测结果不代表未来表现,入市须谨慎。
代码与文档:更多 SDK 与接口文档见 github.com/MaiRuiApi