作 者:老余捞鱼
原创不易,转载请标明出处及原作者。

文中示例仅用于技术讨论,不构成任何操作建议。
量化策略开发应以学习和技术交流为目的。
本号不荐股、不卖课、不承诺收益。
市场有风险,请合法合规投资。
本文约 4500 字 | 预计阅读 8-11 分钟,内有实例代码,建议先收藏。
· · ·
均线交叉策略本来就是个简单命题:当价格站上均线时持有多头,跌破均线时切换成现金。但麻烦的是,你得先选一个回看窗口。
20日均线和200日均线给出的结果会差很多。选小了,信号会变多,手续费、滑点和市场噪声会把利润吃掉。选大了,反应又会太迟钝。手感式拍脑袋,其实是最不稳定的那部分。
这篇文章里,我把窗口选择改成一个可以量化的搜索问题。用线性搜索从2天到300天逐一遍历,再给频繁交易加一点惩罚,让模型自己找出兼顾收益和交易次数的窗口。
先看结果
这个搜索框架的核心,不是找出一个“永远正确”的均线,而是减少窗口选择里的主观成分。
我用苹果2021年1月到2026年8月的日线数据做了复现,最终选出222天作为最优窗口。历史回测里的净回报是72.65%,交易次数是25次,结果里仍然能看到多次空仓保现金的阶段。
先看看这张实际买卖执行图,后面我会把代码和每一步拆开讲。

几个值得记的点:
- 短窗口并不一定更好,交易次数上去后,成本会明显侵蚀净回报。
- 线性搜索虽然朴素,但在这个问题里足够用,因为候选空间只有299个。
- 最优结果来自历史回测,不代表未来一定会重复。
- 把手续费、滑点和交易惩罚一起放进同一套规则,结论才更稳。
- 真正适合你的窗口,还是要结合品种、周期和你的持仓习惯再验证。
策略到底在比什么
均线交叉策略的比较对象很单一:收盘价和一条简单均线。价格在均线上方时,保持多头;价格落到均线下方时,切换成现金。你调整的只是“用过去多少天的收盘价算这条均线”。
把目标拆成两部分:一是净回报,二是交易惩罚。惩罚按交易次数计算,每多一笔交易就扣掉一定比例。这样短窗口就算收益高,也得先回答一个问题:多出来的交易是否值得。
用公式表示,目标函数大概是:
目标值 = 净回报 – 交易惩罚
线性搜索在这里并不需要炫技。搜索空间只有2到300天,一共299个候选值。把每个窗口都跑一遍回测,再把结果排个序,就能看出哪些窗口是“看起来热闹但成本吃利润”,哪些才是“收益和交易次数都更平衡”。
先把环境和数据准备好
工作分成几步:先装依赖,再取数据,再做日期处理,最后可视化。
① 安装依赖
# 安装后续要用到的依赖库 pip install requests pandas python-dotenv matplotlib -q② 加载环境变量和数据接口
下面的代码负责加载保存在 .env 里的 API key,然后请求 EODHD 的历史日线数据。数据范围是2021年1月到2026年8月,品种是苹果。
import osimport requestsimport pandas as pdimport numpy as npimport matplotlib.pyplot as pltfrom dotenv import load_dotenvload_dotenv()# 从环境变量中读取 API keyAPI_KEY = os.getenv("EODHD_API_KEY")if not API_KEY: raise ValueError("EODHD_API_KEY was not found in your .env file")print("API key loaded successfully")③ 下载历史数据并整理日期
先拿到原始 JSON,再转成 DataFrame。日期字段先转成 Pandas 的 datetime,然后设成索引。这样后面画图和切片都会顺手很多。
ticker = "AAPL.US"url = f"https://eodhd.com/api/eod/{ticker}"params = { "api_token": API_KEY, "fmt": "json", "from": "2021-01-01", "to": "2026-08-20", "order": "a"}response = requests.get(url, params=params)response.raise_for_status()data = response.json()df = pd.DataFrame(data)df.head()# 把日期转成 datetime,并设为索引df["date"] = pd.to_datetime(df["date"])df = df.set_index("date")df.head()原始价格走势先看一眼。下面的图就是苹果这段时间的收盘价,后面所有均线、信号和回测,都是在这个数据上继续往下做的。

plt.figure(figsize=(12, 6))plt.plot( df.index, df["close"], label="Close Price")plt.title(f"{ticker} Stock Closing Price")plt.xlabel("Date")plt.ylabel("Price")plt.legend()plt.grid(True)plt.savefig("stock_price_plot.png")plt.show()④ 回测函数是整套实验的核心
主循环函数里做了四件事:算均线、生成信号、按次日开盘价执行、记录现金、持仓和组合价值。
def run_backtest( df, window, initial_cash=10_000.00, transaction_cost=0.001, slippage=0.0005): # 针对某一个均线窗口,运行价格穿越均线的策略回测 # 信号在收盘后生成,交易在次日开盘执行 # 允许买 fractional shares data = df.copy() # 计算简单均线 data["MA"] = data["close"].rolling(window).mean() # 生成信号:收盘价站上均线记为1,否则记为0 data["signal"] = 0 data.loc[data["close"] > data["MA"], "signal"] = 1 data.loc[data["close"] <= data["MA"], "signal"] = 0 data["position_change"] = data["signal"].diff() # 初始化账户状态 cash = initial_cash shares = 0.0 trades = [] portfolio = [] # 按天遍历,执行交易逻辑 for i in range(len(data)): date = data.index[i] close_price = data["close"].iloc[i] # 信号按次日开盘价执行 if i + 1 < len(data): next_open = data["open"].iloc[i + 1] else: next_open = np.nan signal = data["position_change"].iloc[i] # 开多 if signal == 1 and shares == 0 and not np.isnan(next_open): execution_price = next_open * (1 + slippage) amount_to_invest = cash / (1 + transaction_cost) shares_bought = amount_to_invest / execution_price trade_value = shares_bought * execution_price fee = trade_value * transaction_cost cash -= trade_value + fee shares += shares_bought trades.append({ "date": data.index[i + 1], "type": "BUY", "price": execution_price, "shares": shares_bought, "value": trade_value, "fee": fee, "cash_after": cash }) # 清多 elif signal == -1 and shares > 0 and not np.isnan(next_open): execution_price = next_open * (1 - slippage) trade_value = shares * execution_price fee = trade_value * transaction_cost cash += trade_value - fee trades.append({ "date": data.index[i + 1], "type": "SELL", "price": execution_price, "shares": shares, "value": trade_value, "fee": fee, "cash_after": cash }) shares = 0.0 # 记录当日组合市值 stock_value = shares * close_price total_value = cash + stock_value portfolio.append({ "date": date, "cash": cash, "shares": shares, "stock_value": stock_value, "portfolio_value": total_value }) # 汇总结果 portfolio_df = pd.DataFrame(portfolio) portfolio_df.set_index("date", inplace=True) trades_df = pd.DataFrame(trades) final_value = portfolio_df["portfolio_value"].iloc[-1] total_return = (final_value / initial_cash - 1) * 100 return { "window": window, "final_value": final_value, "total_return": total_return, "portfolio": portfolio_df, "trades": trades_df, "data": data }很多新手会把“信号”和“成交”混在一起。这个版本里,信号用收盘价判断,但实际成交用次日开盘价,还要扣掉手续费和滑点。这样回测出来的交易次数才更像真实环境,而不是只看理想穿越点。
把299个窗口挨个跑一遍
搜索范围设为2到300天,共299个候选窗口。对每个窗口都跑一次完整回测,再把净回报、交易次数和惩罚一起记下来。这样不同窗口之间就是同一套比赛规则。
① 搜索范围和线性搜索配置
# 设定搜索边界MIN_WINDOW = 2MAX_WINDOW = 300windows = range(MIN_WINDOW, MAX_WINDOW + 1)print(f"Testing {MIN_WINDOW} to {MAX_WINDOW} day moving averages")print(f"Total windows to test: {len(windows)}")输出会直接告诉你这次要遍历多少个窗口。这个数字很小,所以线性搜索完全够用。真正要注意的是后面的惩罚项,别让模型被多交易几次的高收益带偏。
② 线性搜索主循环
循环里每次都调用同一个回测函数,这样保证每个窗口看到的交易成本、滑点和初始资金都一样。结果放进一个 DataFrame 之后,后面挑最优窗口就很方便。
# 每多一笔交易,额外扣 0.25 个百分点trade_penalty = 0.25optimization_results = []for window in windows: result = run_backtest( df=df, window=window, initial_cash=10_000.00, transaction_cost=0.001, slippage=0.0005 ) number_of_trades = len(result["trades"]) net_return = result["total_return"] penalty = number_of_trades * trade_penalty objective = net_return - penalty optimization_results.append({ "window": window, "final_value": result["final_value"], "total_return": net_return, "number_of_trades": number_of_trades, "penalty": penalty, "objective": objective })optimization_df = pd.DataFrame(optimization_results)optimization_df.head()③ 挑出最优窗口
这里的“最优”不是只看净回报最高,而是看目标函数最高。目标函数已经把交易惩罚算进去了,所以最终选出来的窗口,通常是“收益不错,同时不会乱交易”的那种。
best_result = optimization_df.loc[ optimization_df["objective"].idxmax()]best_window = int(best_result["window"])best_return = best_result["total_return"]best_final_value = best_result["final_value"]best_trades = int(best_result["number_of_trades"])best_penalty = best_result["penalty"]best_objective = best_result["objective"]print(f"Optimal moving-average window: {best_window} days")print(f"Final portfolio value: ${best_final_value:,.2f}")print(f"Net return: {best_return:.2f}%")print(f"Number of trades: {best_trades}")print(f"Trading penalty: {best_penalty:.2f}%")print(f"Optimization objective: {best_objective:.2f}%")这次跑出来的结果是222天窗口。净回报72.65%,交易次数25次,惩罚6.25%,目标值66.40%。这个结果说明,更长的窗口虽然可能错过一些短线波动,但也因此减少了反复进出带来的成本。
④ 把每个窗口的结果都可视化
这张图最能说明问题:

横轴是从2到300天的候选窗口,纵轴是每个窗口的净回报。橙色星标就是最终选出的222天。我们可以很直观地看到,并不是越靠近左边越好。
plt.figure(figsize=(14, 7))plt.plot( optimization_df["window"], optimization_df["total_return"], zorder=1)plt.scatter( best_window, best_return, marker="*", s=500, color="orange", label=f"Best: {best_window}-Day MA", zorder=10)plt.title("Moving Average Window Optimization")plt.xlabel("Moving Average Window (Days)")plt.ylabel("Total Return (%)")plt.legend()plt.grid(True)plt.savefig("optimization_performance_plot.png")plt.show()用最优窗口跑最后一轮回测
找到222天后,再用这个窗口完整回测一次。这一步不只是为了好看,而是为了拿到最终的日线持仓、交易记录和组合曲线,方便后面继续检查。
best_backtest = run_backtest( df=df, window=best_window, initial_cash=10_000.00, transaction_cost=0.001, slippage=0.0005)final_df = best_backtest["data"]portfolio_df = best_backtest["portfolio"]trades_df = best_backtest["trades"]final_value = best_backtest["final_value"]total_return = best_backtest["total_return"]print(f"Optimal MA window: {best_window} days")print(f"Initial capital: $10,000.00")print(f"Final portfolio value: ${final_value:,.2f}")print(f"Total return: {total_return:.2f}%")print(f"Number of trades: {len(trades_df)}")输出里最关键的是三个数字:222天窗口、1万美元本金、25次交易。后面的图和信号,都是基于这组参数继续往下拆的。
价把格和优化后的均线放在一起看
这张图把收盘价和222天均线叠在一起,方便你直观感受这个窗口到底是偏慢还是偏快。对苹果这段时间来说,它明显更偏向“少动”的风格。

plt.figure(figsize=(14, 7))plt.plot( final_df.index, final_df["close"], label="Close Price", zorder=1)plt.plot( final_df.index, final_df["MA"], label=f"{best_window}-Day Moving Average", zorder=1)plt.title( f"Price vs Optimized {best_window}-Day Moving Average")plt.xlabel("Date")plt.ylabel("Price")plt.legend()plt.grid(True)plt.savefig("optimized_moving_average_plot.png")plt.show()把真实买卖点位标出来
下面的图是很多人最关心的部分:策略到底在哪些天开多,哪些天清多。绿色三角代表实际买点,红色三角代表实际卖点。因为交易是次日开盘执行,所以这些点位不一定正好出现在信号当天。

# 生成持仓信号final_df["signal"] = 0final_df.loc[ final_df["close"] > final_df["MA"], "signal"] = 1final_df.loc[ final_df["close"] <= final_df["MA"], "signal"] = 0final_df["position_change"] = final_df["signal"].diff()# 画出价格、均线和实际交易点位plt.figure(figsize=(14, 7))plt.plot( final_df.index, final_df["close"], label="Close Price", zorder=1)plt.plot( final_df.index, final_df["MA"], label=f"{best_window}-Day Moving Average", zorder=1)buy_trades = trades_df[ trades_df["type"] == "BUY"]plt.scatter( buy_trades["date"], buy_trades["price"], marker="^", s=120, label="Buy", color="green", zorder=10)sell_trades = trades_df[ trades_df["type"] == "SELL"]plt.scatter( sell_trades["date"], sell_trades["price"], marker="v", s=120, label="Sell", color="red", zorder=10)plt.title( f"Price Crossover Strategy - {best_window}-Day MA")plt.xlabel("Date")plt.ylabel("Price")plt.legend()plt.grid(True)plt.savefig("trading_signals_plot.png")plt.show()你看这张图时别只盯着买点和卖点。更要看策略在哪些时间段一直空仓持币。222天均线本身就是偏慢的参数,所以很多震荡市里它会选择不动。对量化新人来说,这一点比“抓到多少波”更值得先学会。
组合净值曲线说明回测全过程
最后一幅图是组合市值曲线。最值得注意的,不是最后数字好看不好看,而是中间有几段明显横盘甚至走平。那些阶段说明策略没有强行持仓,而是通过空仓躲掉了一部分不确定性。

plt.figure(figsize=(14, 7))plt.plot( portfolio_df.index, portfolio_df["portfolio_value"], label="Strategy Portfolio", zorder=2)plt.axhline( 10_000, linestyle="--", label="Initial Capital", zorder=1)plt.title( f"Portfolio Value - Optimized {best_window}-Day MA Strategy")plt.xlabel("Date")plt.ylabel("Portfolio Value ($)")plt.legend()plt.grid(True)plt.savefig("portfolio_performance_plot.png")plt.show()最终结果是1万美元变成17264.66美元,净回报72.65%,共25次交易。这个数字我只当实验结果看,不把它包装成某种“年化标准答案”。它更像是在说:如果严格按照这套规则执行,过去这段时间里,222天窗口比随意选一个20天或50天窗口更稳。
这件事给我的提醒
很多人研究均线策略,第一步就是调参数,第二步就开始找“最好用的数字”。这篇文章真正值得借鉴的,是把参数这件事做成一个可重复、可比较、可惩罚过度交易的流程。
线性搜索并不是最高级的方法,但它足够直白易懂,不需要黑箱也能看懂。你能清楚地看到,搜索流程为什么选222天,而不是200天或250天。
真正值得继续做的,不是把222天当唯一答案,而是把同样的流程搬到别的品种、别的周期、别的惩罚系数里再跑一遍。如果不同市场都能给出接近的结论,那才说明这个方法不是过度拟合。
一点实操建议
如果你要自己复现,我建议先从三个地方下手。
第一,把交易成本、滑点和 penalty 设成变量,不要硬编码,后面调参会方便很多。
第二,把结果按年拆开看,不要只看总回报。
第三,再拿一段样本外数据测试,看看222天在未见过的行情里是否还能保持相对稳定。
Be First to Comment