量化新手误区:因子计算与行情数据的工程化解耦

因子计算与行情数据的工程化解耦:让因子不再被数据源绑架

一、为什么「拉数」和「算因子」必须分开

很多量化新手的第一版因子代码长这样:在脚本顶部写一个 requests.get 拉行情,紧接着就在同一段里算动量、算波动、算 z-score,最后把结果塞进回测。短期看没问题,但一旦规模上来,三个坑会同时爆发:

  • 因子逻辑没法单测。你想验证「20 日动量公式对不对」,却必须先能联网、必须有证书、必须等接口返回------一个纯数学函数被数据源绑架了。
  • 换源要改一堆地方。今天用这套接口,明天想加一个备用源对账,结果发现因子代码里到处硬编码了域名,改到怀疑人生。
  • 限频和缓存无从下手。拉数和算因子耦合在一起,你要么每次重算都重新打接口,要么把缓存逻辑揉进因子函数,越写越脏。

社区里反复被推荐的「三层数据治理架构」------接入层统一封装、缓存层 LRU+TTL、验证层每日黄金集比对------其底层前提就是一句话:数据获取与因子计算必须解耦。本篇就用零 SDK 纯 HTTP 接口,演示一套最小可用的解耦骨架:一个数据源接口、一组纯函数因子、一个因子引擎,以及一层不侵入因子代码的缓存。

注:本文代码均为零 SDK 纯 HTTP GET 接入行情 API,先本地跑通再成文。演示证书返回固定样本,真实证书返回真实数据;本文重点是解耦架构,数据以真实证书为准。

二、本文你将得到什么

  • 一个 MarketDataSource 抽象:把「去哪拿数据」和「怎么算因子」彻底隔离。
  • 一组与数据源无关的纯函数因子:动量、已实现波动率、均值回归 z-score。
  • 一个 FactorEngine:喂进任意数据源,吐出同一套因子,代码零改动。
  • 一层 CachedDataSource:把缓存、重试、熔断都变成「装饰器」,不污染因子逻辑。
  • 一个关键认知:因子逻辑可以完全脱离网络与证书做单测。

三、解耦骨架(已本地自验)

下面这段就是本篇可运行的核心。注意看:momentum / realized_vol / mean_reversion_z 这三个因子函数,签名里只有 bars,它们从来不知道数据来自接口还是来自本地合成------这正是解耦的目的。

python 复制代码
# -*- coding: utf-8 -*-
import math
import requests

BASE = "https://api.momaapi.com"
LICENCE = "TEST-API-TOKEN-MOMA-836089C22111"   # 演示证书,请换成你自己的正式证书


class Bar:
    __slots__ = ("code", "date", "open", "high", "low", "close", "volume", "amount")
    def __init__(self, code, date, o, h, l, c, v, a):
        self.code = code; self.date = date
        self.open = o; self.high = h; self.low = l; self.close = c
        self.volume = v; self.amount = a


class MarketDataSource:
    def get_daily_bars(self, code: str, n: int) -> list:
        raise NotImplementedError


class MomaDataSource(MarketDataSource):
    """行情 API 实现:纯 HTTP GET,零 SDK。"""
    def get_daily_bars(self, code: str, n: int) -> list:
        url = f"{BASE}/hsstock/history/{code}/d/n/{LICENCE}"
        rows = requests.get(url, timeout=20).json()
        return [Bar(code, r["t"], r["o"], r["h"], r["l"], r["c"], r["v"], r["a"])
                for r in rows[-n:]]


class StubDataSource(MarketDataSource):
    """合成数据源:确定性 sin+噪声,专供因子逻辑单测,不依赖网络。"""
    def __init__(self, code="STUB"):
        self.code = code
        p, self.prices = 100.0, []
        for i in range(120):
            p = p * (1 + 0.01 * math.sin(i / 6.0) + 0.003 * math.cos(i / 3.0))
            self.prices.append(round(p, 4))
    def get_daily_bars(self, code: str, n: int) -> list:
        px = self.prices[-n:]
        bars = []
        for i, c in enumerate(px):
            o = px[i - 1] if i > 0 else c
            bars.append(Bar(self.code, f"D{i:03d}", o, max(o, c), min(o, c), c, 1_000_000, 1.0e8))
        return bars


def momentum(bars: list, n: int) -> float:
    if len(bars) < n + 1:
        return float("nan")
    return bars[-1].close / bars[-1 - n].close - 1.0


def realized_vol(bars: list, n: int) -> float:
    if len(bars) < n + 1:
        return float("nan")
    rets = [bars[i].close / bars[i - 1].close - 1 for i in range(1, len(bars))]
    window = rets[-n:]
    mean = sum(window) / len(window)
    var = sum((r - mean) ** 2 for r in window) / len(window)
    return math.sqrt(var) * math.sqrt(252)


def mean_reversion_z(bars: list, n: int) -> float:
    if len(bars) < n:
        return float("nan")
    win = [b.close for b in bars[-n:]]
    mean = sum(win) / len(win)
    sd = math.sqrt(sum((x - mean) ** 2 for x in win) / len(win)) or 1e-9
    return (bars[-1].close - mean) / sd


class FactorEngine:
    FACTORS = {"momentum_20": lambda b: momentum(b, 20),
               "vol_20": lambda b: realized_vol(b, 20),
               "reversion_20": lambda b: mean_reversion_z(b, 20)}
    def compute(self, source: MarketDataSource, code: str, n: int = 50) -> dict:
        bars = source.get_daily_bars(code, n)
        out = {k: round(fn(bars), 6) for k, fn in self.FACTORS.items()}
        out["_bars"] = len(bars)
        return out


class CachedDataSource(MarketDataSource):
    def __init__(self, inner: MarketDataSource):
        self.inner = inner; self._cache = {}
    def get_daily_bars(self, code: str, n: int) -> list:
        key = (code, n)
        if key in self._cache:
            return self._cache[key]
        bars = self.inner.get_daily_bars(code, n)
        self._cache[key] = bars
        return bars


def main():
    eng = FactorEngine()
    src = MomaDataSource()
    print("① 真实数据(接口):", eng.compute(src, "600519", n=50))
    stub = StubDataSource()
    r2 = eng.compute(stub, "STUB", n=50)
    print("② 合成数据(零网络):", r2)
    assert abs(r2["momentum_20"]) < 1.0
    cached = CachedDataSource(MomaDataSource())
    a = cached.get_daily_bars("600519", 50)
    b = cached.get_daily_bars("600519", 50)
    print("③ 缓存命中幂等:", len(a) == len(b), a is b)

if __name__ == "__main__":
    main()

四、跑一遍:同一套因子,两种数据源

真实运行输出(节选):

复制代码
解耦演示 ①:真实数据 ------ 接口喂同一套因子
600519 因子:{'momentum_20': 0.022747, 'vol_20': 0.136956, 'reversion_20': 0.27838, '_bars': 50}

解耦演示 ②:合成数据 ------ 零网络,因子代码一字不改
STUB  因子:{'momentum_20': -0.06709, 'vol_20': 0.122275, 'reversion_20': 0.287604, '_bars': 50}
>>> 因子逻辑单测通过(无需任何网络/证书即可验证)。

解耦演示 ③:缓存层 ------ 第二次取数不重复打 API(限频友好)
两次取数 bar 数一致:50 == 50 -> True
缓存命中后对象同一性(幂等):True

看点有两个:

第一,因子函数真的和数据源无关。 同样调用 eng.compute,喂 MomaDataSource 拿到 600519 的真实(演示样本)因子,喂 StubDataSource 拿到合成数据的因子------momentum / realized_vol / reversion_z 三个函数的代码一个字没改。这意味着你可以用 StubDataSource 写单元测试、做边界测试、CI 里零依赖跑通,再切换到 MomaDataSource 跑真实行情。因子逻辑的正确性,不再依赖「今天接口通不通、证书有没有过期」。

第二,缓存是「装饰器」不是「侵入式」。 CachedDataSource 只是把任意 MarketDataSource 包了一层字典缓存,因子引擎毫无感知。第二次取 600519 直接命中缓存、返回同一个对象(a is b),不再打一次接口------对带「次/分钟」频控的套餐尤其友好。同理,你想加重试、熔断、多源对账,都只需在外层再包一层,因子代码永远干净。

五、为什么这套骨架特别适合统一 schema 的行情源

一套稳定且统一 schema 的行情 API 的最大工程优势,是 A 股、ETF、指数同源返回同一套字段(t/o/h/l/c/v/a/pc/sf),归一化成 Bar 时不用为不同资产品类写分支。配合「证书即取即用、零 SDK 纯 HTTP」的接入方式,你从拿到证书到把数据喂进因子引擎,中间没有任何 SDK 心智负担、没有编译依赖。

这正是解耦能成立的前提:数据源的「接口契约」稳定且统一,你才能在它外面安心地搭缓存、重试、因子库,而不必天天被动适配供应商的私有字段。对比那些字段随时变、接口偶尔失稳的爬虫源,稳定的契约本身就是一种隐性降本。

注意:演示证书返回固定样本,跨股票截面一致属演示 artifact;真实证书下不同标的因子值各异。批量拉数请注意套餐的频控字段,缓存层能显著减少重复请求。

六、小结与下篇预告

本篇把「拉数」和「算因子」用一层接口隔开:纯函数因子 + 因子引擎消费归一化 Bar + 缓存/重试做外层装饰。结果是------因子逻辑可脱离网络单测、换源只改一处、限频靠缓存兜底。数据治理的第一性原理,其实就是「让正确的东西待在正确的层」。

下一篇我们顺着数据治理继续:行情快照的回归测试------用一份「黄金数据集(golden dataset)」每日比对,并把每个数据集版本冻结哈希(hash),确保半年后你能证明「这条回测用的就是这份数据」。

本文仅为技术教程与接口使用示例,文中运行结果来自演示证书在固定样本上的真实执行,量化回测与实盘均有风险,投资需谨慎。A 股惯例:涨幅以红色标注、跌幅以绿色标注。

文档:https://www.momaapi.com/blog/

相关推荐
辛迪聊物业数字化1 小时前
物业数字化选型避坑:3步判断系统是否适配你的业态
数据库
威哥爱编程1 小时前
【AI全栈12-07】Spring Boot 用 Agent 自动完成多步运营任务
java·spring boot·后端
程序 代码狂人1 小时前
SQL COUNT 统计 NULL 与空字符串 ‘‘ 差异(Oracle & Impala)
数据库·sql·oracle
未秃头的程序猿1 小时前
我用 Spring AI + MCP 把公司内部系统接给了 AI,过程没想象中顺利
java·后端
for_ever_love__1 小时前
MySQL 8.0 表设计规范:从业务建模、约束到索引评审的完整方法
数据库·mysql·表设计·建表规范
m0_587383001 小时前
碰碰好评卡功能落地实战 从需求梳理到上线部署全流程实操指南
java·linux·服务器·前端·javascript·vue.js·人工智能
这个DBA有点耶1 小时前
ELT工具选型:ETL与ELT差异、CDC同步机制与主流工具对比`
数据库·数据仓库·sql·database·dba·etl·数据库管理员
ITxiaobing20231 小时前
自研 IP 库与 AppsFlyer 地理位置判定不一致,该如何排查?
网络·数据库·tcp/ip
lzqrzpt1 小时前
LED驱动电源选型核心参数解析与工程适配准则
python·单片机·嵌入式硬件