AI 量化学习笔记 约 19 分钟

一台 MacBook 就够了:我的量化研究环境搭建实录

一台 MacBook 就够了:我的量化研究环境搭建实录

AI 量化学习笔记

AI 量化学习笔记 · 第 3 篇

上一篇还在等券商的书面答复,但研究环境没必要跟着停下来。数据、回测和因子研究都放在研究层,不绑定券商终端,现在就能做。 这篇记录我在 MacBook 上搭建研究环境的过程,并用 510300(沪深 300 ETF)跑通日线数据的拉取、入库、复权检查和画图。完整代码放在 https://github.com/wangyiyang/quant

两条边界

前 3 个月不买新硬件。一只 ETF 十年的日线只有两千多行,即使扩到几百只 ETF,也只是几十万行。Apple Silicon 足够处理这个量级,先把现有机器用起来。 研究和执行也要分开。研究层只输出目标仓位;Windows、券商终端和下单接口留到第 4—6 个月处理。这样以后换券商或终端,研究代码不用跟着重写。

先把目录搭好

我不想从一个孤零零的 Jupyter Notebook 开始。Notebook 很适合试想法,但内容一多,很快就分不清哪个 cell 才是最终版本。所以第一天我就把目录、依赖和版本控制搭好。

quant/
├── data/          # DuckDB 文件与原始缓存不进 Git
├── src/
   ├── fetch/     # 数据拉取akshare / baostock
   ├── db/        # 入库与查询
   └── viz/       # 画图
├── notebooks/     # 探索性分析结论必须回流到 src/
├── tests/
├── .env           # 密钥与配置不进 Git
└── README.md

初始化只有几条命令,一次性做完就不用再想:

mkdir -p quant/{data,src/{fetch,db,viz},notebooks,tests}
cd quant
python3.11 -m venv .venv && source .venv/bin/activate
pip install akshare baostock duckdb pandas numpy plotly pytest python-dotenv
pip freeze > requirements.txt
printf 'data/\n.venv/\n.env\n__pycache__/\n' > .gitignore
git init && git add -A && git commit -m "chore: init quant research env"

Notebook 只负责探索;需要复用的逻辑放进 src/ 并补测试。数据文件和密钥不进 Git。

软件栈

环境用 Python 3.11 和 venv,数据处理与画图选 pandas、numpy、plotly。日线数据量不大,先用熟悉的工具,不为暂时用不到的性能引入 polars。行情由 akshare 拉取,baostock 用来交叉核对。 数据落在 DuckDB。CSV 遇到复权因子更新时需要重写文件,历史数据一多就容易失控;SQLite 也能做,但 DuckDB 的列存和分析查询更适合这类工作负载,而且同样只需要一个本地文件。

跑通 510300 日线

流程是拉取、入库、检查复权,再画图。

拉取

akshare 的东财接口不需要 token。adjust 参数决定返回原始价还是复权价,这里两份都保存:

# src/fetch/akshare_daily.py
import akshare as ak
import pandas as pd

COLS = {
    "日期": "date", "开盘": "open", "最高": "high", "最低": "low",
    "收盘": "close", "成交量": "volume", "成交额": "amount",
}

def fetch_etf_daily(symbol="510300", start="20130101", end="20260726", adjust="hfq"):
    """adjust: "" 原始价 / "hfq" 后复权"""
    df = ak.fund_etf_hist_em(
        symbol=symbol, period="daily",
        start_date=start, end_date=end, adjust=adjust,
    ).rename(columns=COLS)
    df["date"] = pd.to_datetime(df["date"]).dt.date
    df["symbol"] = symbol
    df["adjust"] = adjust or "raw"
    return (df[["symbol", "date", "open", "high", "low", "close", "volume", "amount", "adjust"]]
            .sort_values("date").reset_index(drop=True))

入库

入库必须幂等:同一天重复拉取只能留一行,复权因子变化后也能整段回写。这里用主键定位旧记录,先删再插:

# src/db/store.py
import pathlib
import duckdb

DB_PATH = pathlib.Path("data/quant.duckdb")
SCHEMA = """
create table if not exists etf_daily (
  symbol varchar, date date,
  open double, high double, low double, close double,
  volume bigint, amount double, adjust varchar,
  primary key (symbol, date, adjust)
);
"""

def upsert_daily(df):
    with duckdb.connect(str(DB_PATH)) as con:
        con.execute(SCHEMA)
        con.register("incoming", df)
        con.execute("""delete from etf_daily e
                       using incoming i
                       where e.symbol = i.symbol and e.date = i.date and e.adjust = i.adjust""")
        con.execute("insert into etf_daily select * from incoming")
        return con.execute("select count(*) from etf_daily").fetchone()[0]

def load_daily(symbol="510300", adjust="hfq"):
    with duckdb.connect(str(DB_PATH), read_only=True) as con:
        return con.execute("""select date, open, high, low, close, volume
                             from etf_daily where symbol = ? and adjust = ?
                             order by date""", [symbol, adjust]).df()

画图

plotly 负责 K 线和成交量副图,结果写成 HTML,直接用浏览器查看:

# src/viz/candles.py
import plotly.graph_objects as go
from plotly.subplots import make_subplots

def plot_candles(df, symbol="510300", out="data/510300.html"):
    fig = make_subplots(rows=2, cols=1, shared_xaxes=True,
                        row_heights=[0.72, 0.28], vertical_spacing=0.03)
    fig.add_trace(go.Candlestick(x=df["date"], open=df["open"], high=df["high"],
                                 low=df["low"], close=df["close"], name="price"), row=1, col=1)
    fig.add_trace(go.Bar(x=df["date"], y=df["volume"], name="volume"), row=2, col=1)
    fig.update_layout(title=f"{symbol} 日线(后复权)", xaxis_rangeslider_visible=False,
                      height=760, template="plotly_white")
    fig.write_html(out)
    return out

串起来运行

# scripts/run_pipeline.py
from src.fetch.akshare_daily import fetch_etf_daily
from src.db.store import upsert_daily, load_daily
from src.viz.candles import plot_candles

for adjust in ("", "hfq"):
    rows = upsert_daily(fetch_etf_daily(adjust=adjust))
    print(f"adjust={adjust or 'raw':>3} -> 库内累计 {rows} 行")

df = load_daily("510300", adjust="hfq")
print(df.tail(3))
print("chart ->", plot_candles(df))

执行方式有个细节:脚本里用了 from src... 的包导入,必须在项目根目录以模块方式运行;直接 python scripts/run_pipeline.py 会把 scripts/ 当成搜索根,报 ModuleNotFoundError: No module named 'src'

cd quant && source .venv/bin/activate
python -m scripts.run_pipeline

图能正常打开之后,我先检查了复权。 不复权的价格序列会在分红除息日向下跳空,直接拿它计算动量会产生假信号。我的处理是:后复权价格用于研究,原始价格保留下来核对成交。两份数据都在库里后,可以用 SQL 比较同一天的收益率;差值明显不为零的日期,通常就是分红除息日:

-- 找出原始价与后复权价日收益率背离的交易日
with r as (
  select date, adjust,
         close / lag(close) over (partition by adjust order by date) - 1 as ret
  from etf_daily where symbol = '510300'
)
select a.date,
       round(a.ret * 100, 3) as raw_pct,
       round(b.ret * 100, 3) as hfq_pct
from r a join r b on a.date = b.date
where a.adjust = 'raw' and b.adjust = 'hfq'
  and abs(a.ret - b.ret) > 0.002
order by a.date;

这段 SQL 不放进 run_pipeline.py,而是保存为独立脚本 scripts/check_adjust.py。脚本以只读方式连接 data/quant.duckdb,执行上面的查询并打印背离日期;完整实现可以在 GitHub 仓库中查看。 先运行 run_pipeline,把 raw 和 hfq 两份数据入库,再执行 python -m scripts.check_adjust。临时检查也可以安装 DuckDB CLI(brew install duckdb),然后用 duckdb -readonly data/quant.duckdb 打开。只读模式可以避免与正在写库的脚本争用连接。 另一个容易混淆的地方是交易日。「过去 20 天」和「过去 20 个交易日」不是一回事,窗口计算必须按交易记录或交易所日历走,不能直接减 20 个自然日。

用第二个数据源核对

免费数据最麻烦的地方,是错误通常不会主动报出来。我用 baostock 再拉一份 510300 日线,与 akshare 的结果按日期对齐,检查收盘价偏差和单边缺失的日期。

# src/fetch/crosscheck.py
import baostock as bs
import pandas as pd
from src.db.store import load_daily

def fetch_baostock(code="sh.510300", start="2013-01-01", end="2026-07-26"):
    bs.login()
    try:
        rs = bs.query_history_k_data_plus(
            code, "date,close,volume", start_date=start, end_date=end,
            frequency="d", adjustflag="3",  # 3 = 不复权,与 raw 对齐
        )
        if rs.error_code != "0":
            raise RuntimeError(f"baostock 查询失败:{rs.error_code} {rs.error_msg}")
        rows = []
        while rs.error_code == "0" and rs.next():
            rows.append(rs.get_row_data())
        print(f"baostock 获取 {len(rows)} 行,最后一天:{rows[-1][0] if rows else '无数据'}")
    finally:
        bs.logout()

    df = pd.DataFrame(rows, columns=["date", "close", "volume"])
    df["date"] = pd.to_datetime(df["date"])
    return df.astype({"close": float, "volume": float})

def crosscheck(tol=0.005):
    a = load_daily("510300", adjust="raw").rename(
        columns={"close": "close_ak", "volume": "vol_ak"}
    )
    b = fetch_baostock().rename(
        columns={"close": "close_bs", "volume": "vol_bs"}
    )
    a["date"] = pd.to_datetime(a["date"])
    b["date"] = pd.to_datetime(b["date"])

    m = a.merge(b, on="date", how="outer", indicator=True)
    only = m[m["_merge"] != "both"][["date", "_merge"]]
    both = m[m["_merge"] == "both"].copy()
    both["close_diff"] = (
        (both["close_ak"] - both["close_bs"]).abs() / both["close_bs"]
    )
    bad = both[both["close_diff"] > tol][
        ["date", "close_ak", "close_bs", "close_diff"]
    ]
    print(f"共同交易日 {len(both)},收盘价一致率 {1 - len(bad) / len(both):.4%}")
    print(f"仅单边存在的日期 {len(only)} 天")
    return bad.sort_values("date"), only

注意 adjustflag="3" 是不复权,必须和库里的 raw 那份比,拿复权价去对不复权价只会得到一堆假差异。 结果有两个数字值得记下来。两边共有 134 个交易日重叠,收盘价一致率为 100%,没有发现超过阈值的差异。与此同时,3157 个日期全部是 left_only,也就是 akshare 有数据、baostock 没有。baostock 这次只返回了 134 行,最后一天是 2026-07-24。 抓取过程没有报错,因此这里能确认的是:baostock 对本次 510300 查询只返回了较短的一段历史。重叠区间可以完成双源核对,更早的数据仍然只有 akshare 一个来源。后面做长期回测时,我会把这段数据边界写进结果说明,不把「部分区间验证通过」说成「全部历史都可靠」。 研究环境先停在这里。下一步开始写回测器,先保证每一笔交易都能复现。

本系列不构成投资建议。终端权限、资金门槛及程序化交易要求以券商的最新书面答复为准。

文档信息

京ICP备2021015985号-1