Skip to content

别再随手猜均线参数了,线性搜索更靠谱

作 者:老余捞鱼

原创不易,转载请标明出处及原作者。

文中示例仅用于技术讨论,不构成任何操作建议。
量化策略开发应以学习和技术交流为目的。
本号不荐股、不卖课、不承诺收益。
市场有风险,请合法合规投资。

本文约 4500 字 | 预计阅读 8-11 分钟,内有实例代码,建议先收藏。

· · ·

均线交叉策略本来就是个简单命题:当价格站上均线时持有多头,跌破均线时切换成现金。但麻烦的是,你得先选一个回看窗口。

20日均线和200日均线给出的结果会差很多。选小了,信号会变多,手续费、滑点和市场噪声会把利润吃掉。选大了,反应又会太迟钝。手感式拍脑袋,其实是最不稳定的那部分。

这篇文章里,我把窗口选择改成一个可以量化的搜索问题。用线性搜索从2天到300天逐一遍历,再给频繁交易加一点惩罚,让模型自己找出兼顾收益和交易次数的窗口。

先看结果

这个搜索框架的核心,不是找出一个“永远正确”的均线,而是减少窗口选择里的主观成分。

我用苹果2021年1月到2026年8月的日线数据做了复现,最终选出222天作为最优窗口。历史回测里的净回报是72.65%,交易次数是25次,结果里仍然能看到多次空仓保现金的阶段。

先看看这张实际买卖执行图,后面我会把代码和每一步拆开讲。

图1:优化后的均线交叉策略实际买卖执行点位

几个值得记的点:

  • 短窗口并不一定更好,交易次数上去后,成本会明显侵蚀净回报。
  • 线性搜索虽然朴素,但在这个问题里足够用,因为候选空间只有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()

原始价格走势先看一眼。下面的图就是苹果这段时间的收盘价,后面所有均线、信号和回测,都是在这个数据上继续往下做的。

图2:苹果2021年1月至2026年8月的收盘价走势;
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%。这个结果说明,更长的窗口虽然可能错过一些短线波动,但也因此减少了反复进出带来的成本。

④ 把每个窗口的结果都可视化

这张图最能说明问题:

图3:不同均线窗口的净回报曲线与最优窗口标注;

横轴是从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天均线叠在一起,方便你直观感受这个窗口到底是偏慢还是偏快。对苹果这段时间来说,它明显更偏向“少动”的风格。

图4:收盘价与优化后的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()

把真实买卖点位标出来

下面的图是很多人最关心的部分:策略到底在哪些天开多,哪些天清多。绿色三角代表实际买点,红色三角代表实际卖点。因为交易是次日开盘执行,所以这些点位不一定正好出现在信号当天。

图5:优化策略下的实际买卖执行点位;
# 生成持仓信号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天均线本身就是偏慢的参数,所以很多震荡市里它会选择不动。对量化新人来说,这一点比“抓到多少波”更值得先学会。

组合净值曲线说明回测全过程

最后一幅图是组合市值曲线。最值得注意的,不是最后数字好看不好看,而是中间有几段明显横盘甚至走平。那些阶段说明策略没有强行持仓,而是通过空仓躲掉了一部分不确定性。

图6:优化策略下的组合市值曲线;
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天在未见过的行情里是否还能保持相对稳定。


#量化策略 #均线策略 #线性搜索 #Python回测 #参数优化 #交易成本 #AAPL #历史回测

Published inAI&Invest专栏

Be First to Comment

    发表回复