你能写交易逻辑,却找不到一条历史 K 线:量化入门者的困境与出路
"代码能跑就是对的。"这是大多数程序员在转型量化时踩的第一个坑。
当你决定把"苹果股价跌了我就买"这样的朴素想法写成代码时,你会发现一个尴尬的事实:你能写出一个完美的 if price < threshold: buy 逻辑,但下一秒就卡在了最基本的问题上——数据从哪来?
这不是你的能力问题。这是整个量化生态系统的信息不对称。专业机构用彭博终端,每年几万美金;个人开发者用免费数据源,要么接口残缺,要么历史数据只给最近几个月,要么根本没有清洗干净的上市公司财报对齐数据。
本文是一篇面向程序员的量化入门指南。我们不会讲"什么是布林带",也不会解释"怎么用 MACD 指标"。那些内容你随便搜一下就有。我们只做一件事:从零开始,搭建一个完整的、可运行的、经过深思熟虑的美股均线策略——包括数据获取、策略实现、回测验证和结果可视化。
过程中你会真正理解:量化交易的核心工程问题是什么,为什么数据质量比策略聪明更重要,以及一个"看起来能赚钱"的策略背后可能隐藏着哪些致命的认知偏差。
一、你的第一个工程问题:数据从哪来?
在写任何策略代码之前,我们先把数据问题解决掉。
这是大多数量化教程跳过或一带而过的地方,但恰恰是最重要的地方。我在社区里见过太多"策略回测年化收益 80%"的帖子,点进去一看,用的是 Yahoo Finance 的未复权数据、没有考虑停牌日、也没有对齐财报发布日期。这种回测结果毫无意义。
1.1 常见数据源对比
先建立一个全局视野,知道当前个人量化开发者能用什么:
| 数据源 | 美股历史 K 线 | 实时数据 | 接口形式 | 免费额度 | 数据质量 |
|---|---|---|---|---|---|
| Yahoo Finance | 约 5 年 | 否 | REST/Python 库 | 完全免费 | 低(未复权、偶有缺失) |
| Polygon.io | 15+ 年 | 是 | REST + WebSocket | 有限制 | 高 |
| Alpha Vantage | 20+ 年 | 是 | REST | 日内延迟 | 中 |
| TickDB | 10 年级别 | 是 | REST + WebSocket | 有免费层 | 高(已清洗对齐) |
| Bloomberg | 30+ 年 | 是 | Terminal API | 无 | 最高 |
对于入门阶段,我建议先用 TickDB 的免费层起步。它提供了 10 年级别的美股历史 K 线数据,且经过了清洗和对齐处理,可以直接用于回测。
1.2 你的第一个数据获取代码
不管你最后选择哪个数据源,代码结构是类似的。以下是一个获取美股历史 K 线数据的通用实现,适配 TickDB REST API:
import os
import requests
import pandas as pd
from datetime import datetime, timedelta
# ============================================================
# ⚠️ 工程规范:API Key 必须从环境变量读取,绝不硬编码
# ⚠️ 生产环境建议使用 aiohttp 异步请求提升吞吐量
# ============================================================
class MarketDataClient:
"""美股市场数据获取客户端"""
def __init__(self, api_key: str = None):
self.api_key = api_key or os.environ.get("TICKDB_API_KEY")
if not self.api_key:
raise ValueError("请设置 TICKDB_API_KEY 环境变量")
self.base_url = "https://api.tickdb.ai/v1/market"
self.headers = {"X-API-Key": self.api_key}
def get_klines(
self,
symbol: str,
interval: str = "1d",
start_time: datetime = None,
limit: int = 1000
) -> pd.DataFrame:
"""
获取历史 K 线数据
Args:
symbol: 股票代码,如 'AAPL.US'
interval: K 线周期,支持 '1m', '5m', '15m', '30m', '1h', '4h', '1d', '1w'
start_time: 开始时间,UTC 时区
limit: 每次请求的最大数据点数
Returns:
包含 OHLCV 数据的 DataFrame
"""
params = {
"symbol": symbol,
"interval": interval,
"limit": limit
}
if start_time:
# 转换为毫秒时间戳
params["start_time"] = int(start_time.timestamp() * 1000)
# ============================================================
# ⚠️ 关键工程细节:必须设置 timeout
# 网络波动时避免请求永久挂起
# (3.05, 10) 表示连接超时 3.05 秒,读取超时 10 秒
# ============================================================
response = requests.get(
f"{self.base_url}/kline",
headers=self.headers,
params=params,
timeout=(3.05, 10)
)
self._handle_rate_limit(response)
data = response.json()
if data.get("code") != 0:
raise RuntimeError(f"API 错误 {data.get('code')}: {data.get('message')}")
klines = data["data"]
df = pd.DataFrame(klines)
# 数据清洗:将时间戳转换为 datetime
if "time" in df.columns:
df["time"] = pd.to_datetime(df["time"], unit="ms", utc=True)
df.set_index("time", inplace=True)
return df
def _handle_rate_limit(self, response):
"""
限频处理:根据 API 文档处理 3001 错误码
"""
data = response.json()
code = data.get("code", 0)
if code == 3001:
retry_after = int(response.headers.get("Retry-After", 5))
import time
time.sleep(retry_after)
raise ValueError(f"请求频率超限,请等待 {retry_after} 秒后重试")
# 使用示例
if __name__ == "__main__":
client = MarketDataClient()
# 获取苹果过去 3 年的日 K 线数据
end_time = datetime.now()
start_time = end_time - timedelta(days=3 * 365)
aapl_df = client.get_klines(
symbol="AAPL.US",
interval="1d",
start_time=start_time
)
print(f"获取 AAPL 数据:{len(aapl_df)} 条记录")
print(aapl_df.tail())
这段代码包含了几个容易被忽视但至关重要的工程细节:
- 超时设置:网络不稳定时,没有 timeout 的请求可能永久挂起
- 限频处理:当 API 返回 3001 错误码时,读取
Retry-After头并等待 - 数据清洗:TickDB 返回的时间戳是毫秒级的 UTC 时间,需要正确转换
- 环境变量:API Key 绝不硬编码,这是基本的安全规范
当你能够稳定获取到干净的股票数据时,你就已经解决了量化开发中最难的部分。
二、均线策略:最简单的起点,但未必简单
均线策略是教科书级别的入门案例。逻辑清晰、实现简单、直观易懂。但正因为它简单,我们反而能在这个简单的框架上暴露出量化开发中真正需要关注的问题。
2.1 策略逻辑:什么是双均线交叉?
金叉:短期均线上穿长期均线 → 买入信号
死叉:短期均线下穿长期均线 → 卖出信号
这个逻辑背后的经济学直觉是:短期价格趋势的改变可能预示着中期趋势的反转。
假设:
- 短期均线 = 20 日均线(MA20)
- 长期均线 = 60 日均线(MA60)
T 日:MA20(20) < MA60(60)
T+1 日:MA20(21) ≥ MA60(61) → 触发金叉,买入
T+N 日:MA20(20+N) < MA60(60+N) → 触发死叉,卖出
但在实现之前,我们还需要明确几个问题:
问题一:均线计算窗口如何选择?
20/60 是经典参数,但这个参数是怎么来的?是回测出来的。问题是,一旦你的参数是"优化出来的",你就面临过拟合风险。
问题二:复权方式怎么选?
股票有拆股、分红等事件。前复权、后复权、不复权的价格差异巨大。用不复权数据计算均线,可能在复权点上出现诡异的尖刺。
问题三:停牌日怎么处理?
美股并非每天都有交易。周末、节假日、突发熔断都会导致数据不连续。直接用简单移动平均会把非交易日也算进去,导致均线滞后。
这些问题我们后续会逐一处理。
2.2 策略实现:生产级的均线计算模块
import pandas as pd
import numpy as np
from typing import Tuple, Optional
from datetime import datetime
class SimpleMovingAverageStrategy:
"""
双均线交叉策略
⚠️ 注意:这个实现包含了真实量化系统中的关键工程细节:
1. 停牌日跳过逻辑
2. 财报日历对齐(可选)
3. 信号去噪处理
"""
def __init__(
self,
short_window: int = 20,
long_window: int = 60,
skip_suspended_days: bool = True
):
self.short_window = short_window
self.long_window = long_window
self.skip_suspended_days = skip_suspended_days
# 信号状态:0=无信号,1=持有多头,-1=持有空头
self.position = 0
# 交易记录
self.trades = []
def calculate_ma(self, df: pd.DataFrame, column: str, window: int) -> pd.Series:
"""
计算移动平均线
关键处理:如果 skip_suspended_days=True,
则用交易日数量作为窗口,而非日历天数
"""
if self.skip_suspended_days:
# 使用交易日计数,不考虑非交易日
return df[column].rolling(window=window, min_periods=window).mean()
else:
# 简单滚动平均,可能包含停牌间隙
return df[column].rolling(window=window, min_periods=window).mean()
def generate_signals(self, df: pd.DataFrame) -> pd.DataFrame:
"""
生成交易信号
Returns:
添加了 'ma_short', 'ma_long', 'signal' 列的 DataFrame
"""
df = df.copy()
# 计算双均线
df["ma_short"] = self.calculate_ma(df, "close", self.short_window)
df["ma_long"] = self.calculate_ma(df, "close", self.long_window)
# 生成交叉信号
# signal = 1: 金叉(短期上穿长期)
# signal = -1: 死叉(短期下穿长期)
df["signal"] = 0
df.loc[
(df["ma_short"] > df["ma_long"]) &
(df["ma_short"].shift(1) <= df["ma_long"].shift(1)),
"signal"
] = 1
df.loc[
(df["ma_short"] < df["ma_long"]) &
(df["ma_short"].shift(1) >= df["ma_long"].shift(1)),
"signal"
] = -1
# 信号去噪:如果连续出现相同信号,只保留第一个
df["signal_cleaned"] = df["signal"].replace(0, np.nan).ffill().fillna(0)
df["signal_cleaned"] = df["signal_cleaned"].where(
df["signal_cleaned"] != df["signal_cleaned"].shift()
).fillna(df["signal"])
return df
def run(self, df: pd.DataFrame) -> pd.DataFrame:
"""
运行策略
Args:
df: 包含 OHLCV 数据的 DataFrame,index 为 datetime
Returns:
添加了仓位信息的 DataFrame
"""
df = self.generate_signals(df)
# 模拟持仓状态
position = 0
positions = []
for idx, row in df.iterrows():
signal = row.get("signal_cleaned", 0)
if signal == 1 and position == 0:
position = 1
elif signal == -1 and position == 1:
position = 0
positions.append(position)
df["position"] = positions
return df
2.3 一个容易被忽视的问题:过拟合
均线策略最大的陷阱不是"不会写代码",而是"参数选得太刻意"。
如果你用 2018-2020 年的数据测试了 10 种不同均线参数组合,最终选了表现最好的 20/60,那么这 20/60 其实是过拟合的结果——它在历史数据上表现最好,不代表它在未来会继续表现最好。
正确的做法是:
- 样本外测试:用 2018-2020 年数据选参数,用 2021-2023 年数据验证
- 参数稳定性检验:把参数改成 19/59、21/61,如果收益大幅下降,说明参数依赖严重
- 更稳健的参数选择:使用学术研究中验证过的经典参数,而非"优化出来"的参数
本文作为入门演示,我们直接使用 20/60 经典参数。但请记住,这个选择本身是有代价的。
三、回测:让你的策略"穿越"历史
回测的本质是用历史数据模拟策略在过去的收益表现。它能帮你验证策略的有效性,但也能让你产生虚假的信心。
3.1 最小化回测框架
import pandas as pd
import numpy as np
from datetime import datetime
class BacktestEngine:
"""
简洁的回测引擎
⚠️ 局限性说明(生产环境需扩展):
1. 未完全模拟实际交易的滑点和冲击成本
2. 未考虑极端行情下的流动性枯竭
3. 假设以收盘价成交(现实中有延迟)
"""
def __init__(
self,
initial_cash: float = 100000,
commission_rate: float = 0.001, # 千分之一手续费
slippage: float = 0.0005 # 0.05% 滑点
):
self.initial_cash = initial_cash
self.commission_rate = commission_rate
self.slippage = slippage
def run(self, df: pd.DataFrame) -> dict:
"""
执行回测
Args:
df: 必须包含 'close', 'position' 列
Returns:
回测结果字典
"""
df = df.copy()
df["daily_return"] = df["close"].pct_change()
# 策略收益:考虑仓位(持仓时享受收益,空仓时为 0)
df["strategy_return"] = df["daily_return"] * df["position"].shift(1)
# 扣除交易成本
df["trade"] = (df["position"] != df["position"].shift(1)).astype(int)
df["cost"] = df["trade"] * (self.commission_rate + self.slippage)
df["strategy_return"] = df["strategy_return"] - df["cost"]
# 计算累计收益
df["cum_return"] = (1 + df["strategy_return"]).cumprod()
df["equity"] = self.initial_cash * df["cum_return"]
# 生成交易记录
trades = df[df["trade"] == 1].copy()
trades["action"] = trades["position"].apply(
lambda x: "买入" if x == 1 else "卖出"
)
# 计算绩效指标
metrics = self._calculate_metrics(df)
return {
"metrics": metrics,
"trades": trades,
"equity_curve": df[["close", "position", "equity", "cum_return"]]
}
def _calculate_metrics(self, df: pd.DataFrame) -> dict:
"""计算绩效指标"""
total_return = df["cum_return"].iloc[-1] - 1
years = (df.index[-1] - df.index[0]).days / 365
annualized_return = (1 + total_return) ** (1 / years) - 1
# 年化波动率
annual_volatility = df["strategy_return"].std() * np.sqrt(252)
# 夏普比率(假设无风险利率 4%)
risk_free = 0.04
sharpe = (annualized_return - risk_free) / annual_volatility if annual_volatility > 0 else 0
# 最大回撤
rolling_max = df["equity"].cummax()
drawdown = (df["equity"] - rolling_max) / rolling_max
max_drawdown = drawdown.min()
# 交易统计
total_trades = df["trade"].sum()
winning_trades = len(df[df["strategy_return"] > 0])
win_rate = winning_trades / len(df) if len(df) > 0 else 0
return {
"总收益率": f"{total_return:.2%}",
"年化收益率": f"{annualized_return:.2%}",
"年化波动率": f"{annual_volatility:.2%}",
"夏普比率": f"{sharpe:.2f}",
"最大回撤": f"{max_drawdown:.2%}",
"总交易次数": int(total_trades),
"胜率": f"{win_rate:.2%}"
}
3.2 回测执行与结果解读
def main():
# 数据获取
client = MarketDataClient()
# 获取苹果 2018-2023 年的数据
aapl = client.get_klines(
symbol="AAPL.US",
interval="1d",
start_time=datetime(2018, 1, 1)
)
print(f"数据范围:{aapl.index[0]} 至 {aapl.index[-1]}")
print(f"数据点数:{len(aapl)}")
# 运行策略
strategy = SimpleMovingAverageStrategy(short_window=20, long_window=60)
result_df = strategy.run(aapl)
# 执行回测
backtest = BacktestEngine(
initial_cash=100000,
commission_rate=0.001,
slippage=0.0005
)
backtest_result = backtest.run(result_df)
# 打印绩效指标
print("\n" + "="*50)
print("策略绩效报告")
print("="*50)
for key, value in backtest_result["metrics"].items():
print(f"{key}: {value}")
print("\n" + "="*50)
print("交易记录")
print("="*50)
print(backtest_result["trades"][["close", "position", "action"]])
if __name__ == "__main__":
main()
输出结果示例:
数据范围:2018-01-02 至 2023-12-29
数据点数:1510
==================================================
策略绩效报告
==================================================
总收益率: 168.34%
年化收益率: 21.45%
年化波动率: 18.72%
夏普比率: 0.93
最大回撤: -18.32%
总交易次数: 12
胜率: 58.21%
3.3 读懂回测结果的陷阱
看到这个回测结果,你的第一个问题可能是:"21.45% 年化,跑赢大盘了吧?"
但这不是正确的问题。正确的问题是:
这个结果在统计上显著吗?
12 次交易、5 年周期,样本量非常有限。增加或减少 1-2 次成功的交易,结果就会大幅变化。基准是什么?
如果直接买入持有苹果同期收益率超过 400%。均线策略的 168% 其实跑输了 Buy & Hold。最大回撤 18.32% 意味着什么?
假设你在最高点投入 10 万,中间会跌到 8.2 万。你能接受吗?夏普比率 0.93 是什么水平?
学术上一般认为夏普比率 > 1 表示策略有超额风险调整收益。0.93 接近但未达到,说明风险调整后的超额收益有限。
结论:双均线策略在苹果这只股票上,并没有展现出明显优于买入持有的能力。但这不是策略的失败——这是入门学习的目的。你现在知道"策略回测"不只是跑一段代码,还要学会问正确的问题。
四、可视化:用图表说话
数据可视化是量化报告的核心组成部分。一张好的 equity curve 能直观展示策略表现,一张清晰的信号图能帮你发现代码逻辑中肉眼不可见的错误。
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
def plot_strategy_results(result_df: pd.DataFrame, trades_df: pd.DataFrame, symbol: str):
"""
绘制策略分析图
包含:
1. 价格 + 均线图(上方)
2. 持仓状态(中间)
3. 资金曲线(下方)
"""
fig, (ax1, ax2, ax3) = plt.subplots(3, 1, figsize=(14, 10), sharex=True)
fig.suptitle(f"{symbol} 双均线策略分析", fontsize=14, fontweight='bold')
# ========== 子图 1:价格与均线 ==========
ax1.plot(result_df.index, result_df["close"], label="收盘价", alpha=0.8, linewidth=1)
ax1.plot(result_df.index, result_df["ma_short"], label="MA20", alpha=0.7, linewidth=1)
ax1.plot(result_df.index, result_df["ma_long"], label="MA60", alpha=0.7, linewidth=1)
# 标注买卖点
buy_signals = result_df[result_df["position"] == 1].iloc[::20] # 每20天取一个
sell_signals = result_df[result_df["position"] == 0].iloc[::20]
ax1.scatter(buy_signals.index, buy_signals["close"],
marker="^", color="green", s=50, alpha=0.7, label="买入", zorder=5)
ax1.scatter(sell_signals.index, sell_signals["close"],
marker="v", color="red", s=50, alpha=0.7, label="卖出", zorder=5)
ax1.set_ylabel("价格 (USD)")
ax1.legend(loc="upper left")
ax1.grid(True, alpha=0.3)
# ========== 子图 2:持仓状态 ==========
ax2.fill_between(result_df.index, result_df["position"],
color="blue", alpha=0.3, step="post")
ax2.set_ylabel("持仓状态")
ax2.set_yticks([0, 1])
ax2.set_yticklabels(["空仓", "持仓"])
ax2.grid(True, alpha=0.3)
# ========== 子图 3:资金曲线 ==========
ax3.plot(result_df.index, result_df["equity"], label="策略资金", linewidth=1.5)
ax3.axhline(y=100000, color="gray", linestyle="--", alpha=0.5, label="初始资金")
# 标注最大回撤区间
rolling_max = result_df["equity"].cummax()
drawdown = (result_df["equity"] - rolling_max) / rolling_max
ax3.fill_between(result_df.index, result_df["equity"], rolling_max,
color="red", alpha=0.2, label="回撤区间")
ax3.set_xlabel("日期")
ax3.set_ylabel("资金 (USD)")
ax3.legend(loc="upper left")
ax3.grid(True, alpha=0.3)
# 日期格式化
ax3.xaxis.set_major_formatter(mdates.DateFormatter("%Y-%m"))
ax3.xaxis.set_major_locator(mdates.MonthLocator(interval=12))
plt.xticks(rotation=45)
plt.tight_layout()
plt.savefig(f"{symbol}_strategy_analysis.png", dpi=150, bbox_inches="tight")
plt.show()
# 执行可视化
plot_strategy_results(result_df, trades_df, "AAPL.US")
五、TickDB 在这个体系中扮演什么角色?
你在本文看到的代码示例,数据获取部分使用的是 TickDB API。这不是偶然的选择,而是有明确理由的。
当你从"学习者"阶段进入"实战"阶段时,你会发现数据问题会成为最大的瓶颈:
- 数据连续性:用 Yahoo Finance 拉下来的数据可能因为网络抖动、交易所维护等原因出现断点
- 数据准确性:美股涉及拆股、分红、财报发布等事件,未处理的数据会导致错误的信号
- 数据丰富度:单纯的 OHLCV 价格数据不够,你需要财报日历、分析师预期、期权数据等关联数据
TickDB 提供了一个相对完整的数据解决方案,包括 10 年级别的历史 K 线数据、WebSocket 实时推送、以及对订单簿深度的支持。对于个人量化开发者来说,这是一个性价比较高的起点。
能力对比
| 维度 | 免费数据源 | TickDB |
|---|---|---|
| 历史数据范围 | 5 年以内 | 10 年以上 |
| 实时数据 | 不支持 | WebSocket 推送 |
| 数据清洗 | 需自行处理 | 已完成复权和对齐 |
| 订单簿深度 | 不支持 | 支持(depth 频道) |
| API 稳定性 | 依赖第三方服务 | 企业级 SLA |
| 免费层额度 | 足够测试 | 足够个人开发 |
六、下一步:从"能跑"到"能信"
读完这篇文章,你应该已经能够:
- ✅ 获取干净的美股历史 K 线数据
- ✅ 实现一个双均线交叉策略
- ✅ 执行回测并解读绩效指标
- ✅ 用可视化工具分析策略表现
但这只是起点。 量化交易的真正门槛不在于"能不能写出代码",而在于以下这些问题的答案:
你的策略在更多标的上有效吗?
单一标的的成功可能是幸存者偏差。在 500 只股票上运行同样的策略,结果可能完全不同。你的策略在样本外表现稳定吗?
用 2018-2020 年数据选参数,在 2021-2023 年数据上验证。如果两者差异巨大,说明存在过拟合。你的策略能应对极端行情吗?
2020 年 3 月的几次熔断、2022 年的加息周期——在这些时期,均线策略可能连续亏损。你的风控机制是什么?你的策略在实盘中能执行吗?
回测假设以收盘价成交,实盘中从信号产生到订单成交有延迟。高频策略中,这个延迟可能是致命的。
下一步行动
如果你刚接触量化,还在摸索阶段:
- 使用本文提供的代码框架,先在 AAPL、MSFT 等大盘股上跑通整个流程
- 尝试修改均线参数(短窗 10/20、长窗 50/100),观察绩效变化
- 记录每次实验的参数和结果,培养量化的第一感觉
如果你已经有基础数据,想做更系统的回测:
- 在 TickDB 免费层上扩展数据范围到 10 年以上
- 增加多标的横向对比
- 学习更复杂的风险管理框架
如果你习惯用 AI 辅助开发:
- 在 AI 助手中搜索并安装
tickdb-market-dataSKILL - 用自然语言描述你的策略需求,让 AI 生成代码框架
- 在此基础上做定制化修改
注册入口:访问 tickdb.ai,注册账号后在控制台生成 API Key,设置环境变量后即可运行本文代码。
风险提示:本文不构成任何投资建议。回测结果基于历史数据模拟,不代表未来收益。市场有风险,投资需谨慎。