程序员量化交易实战 09:从 K 线到第一个可解释因子信号
原创 · 约 11 分钟阅读 · 阅读 --
Last updated on

程序员量化交易实战 09:从 K 线到第一个可解释因子信号

作者: Alex Xiang


程序员量化交易实战 09:从 K 线到第一个可解释因子信号

古董级程序员,大厂出来后一直在创业公司,现在仍活跃在一线做 AI 相关的开发。这个专栏会把一个 A 股量化平台从 0 到 1 拆开写:数据、策略、回测、模拟盘、提醒和生产化,尽量用真实代码和真实运行结果说话。更完整的更新也会同步到微信公众号「字与码」。

第 8 篇把原始 K 线清洗成了统一的 CleanMarketBar。现在可以写因子了。

这里先不追求复杂。第一组因子只做四件事:日收益、短均线、长均线、动量和波动率。它们足够简单,也足够暴露量化工程的几个关键问题:窗口、缺失值、信号解释和测试边界。

程序员量化交易实战第九篇封面

因子不是神秘公式

因子可以先理解成“把原始数据变成策略能消费的特征”。

比如:

  • 收盘价连续上涨,动量可能为正。
  • 短均线高于长均线,趋势可能偏强。
  • 波动率太高,即使上涨也可能需要谨慎。

这些判断都不是保证收益的规则。它们只是把行情数据变成更容易比较的工程对象。

因子信号面板

先定义因子点

第 9 章新增 app/factors.py,核心对象是 FactorPoint

@dataclass(frozen=True)
class FactorPoint:
    symbol: str
    trade_date: date
    close: float
    return_1d: float | None
    ma_short: float | None
    ma_long: float | None
    momentum: float | None
    volatility: float | None
    signal: str

这里故意允许 None。窗口不够时,均线、动量、波动率都不应该硬算。很多回测 bug 就来自“前几天数据不够,代码却填了 0”。

均线要显式处理窗口

短均线和长均线用同一个函数:

def simple_moving_average(values: list[float], window: int) -> list[float | None]:
    if window <= 0:
        raise ValueError("window must be positive")
    out: list[float | None] = []
    running = 0.0
    for index, value in enumerate(values):
        running += value
        if index >= window:
            running -= values[index - window]
        out.append(round(running / window, 6) if index + 1 >= window else None)
    return out

这段代码没有 pandas,便于读者直接理解窗口计算过程。真实生产里可以换成更高效的向量化实现,但语义要保持一致。

收益率和波动率

日收益率从第二天开始才有:

def daily_returns(values: list[float]) -> list[float | None]:
    out: list[float | None] = [None]
    for previous, current in zip(values, values[1:], strict=False):
        out.append(round(current / previous - 1, 6) if previous else None)
    return out

波动率用滚动窗口,最后乘以 252 做年化:

variance = sum((value - mean) ** 2 for value in sample) / (len(sample) - 1)
out.append(round(math.sqrt(variance * 252), 6))

这里不是为了预测未来,只是给后续策略一个风险感知输入。

从因子到信号

build_factor_points() 会把每一天标成三类信号:

if ma_short[index] > ma_long[index] and momentum > 0 and vol[index] < 0.45:
    signal = "buy_watch"
elif momentum < -0.08 or vol[index] >= 0.65:
    signal = "risk_watch"
else:
    signal = "observe"

这不是交易圣杯。它只是一个明确、可解释、可测试的第一版信号。

我更关心的是工程性质:信号为什么出现,能不能复现,边界条件能不能测。如果这三个问题回答不了,策略复杂度越高越危险。

测试信号比测试收益更重要

第 9 章的测试里有两个关键场景。

稳定上行应该给出 buy_watch

closes = [10 + index * 0.1 for index in range(40)]
points = build_factor_points(_bars(closes), short_window=5, long_window=20, volatility_window=5)
assert points[-1].signal == "buy_watch"

持续下行应该给出 risk_watch

closes = [20 - index * 0.2 for index in range(40)]
points = build_factor_points(_bars(closes), short_window=5, long_window=20, volatility_window=5)
assert points[-1].signal == "risk_watch"

这类测试不证明策略能赚钱,但能证明代码没有把趋势方向、窗口边界和风险阈值写反。

本篇实战任务

拉取第 9 章代码:

git clone https://github.com/ax2/zi-quant-platform.git
cd zi-quant-platform
git checkout chapter-09
uv sync --extra dev
uv run pytest

只跑因子测试:

uv run pytest tests/test_factors.py

第 9 章全量测试通过:164 passed,仍只有既有 FastAPI deprecation warning。

本章更新与代码仓库

本章更新内容:

  • 新增 app/factors.py
  • 实现日收益、短均线、长均线、动量、年化波动率和三类信号。
  • 新增 tests/test_factors.py,覆盖窗口边界、上涨信号和风险信号。

代码仓库:

https://github.com/ax2/zi-quant-platform

本章代码:

git clone https://github.com/ax2/zi-quant-platform.git
cd zi-quant-platform
git checkout chapter-09
uv sync --extra dev
uv run pytest tests/test_factors.py

本篇小结

因子不是为了把数学公式堆上去,而是把行情数据转成可解释、可测试、可复现的中间层。

第 9 篇完成了第一版因子信号。下一篇我们把信号接进最小回测循环,看它如何变成买入、卖出、现金、持仓和权益曲线。