python量化实操:冷热分层存储

痛点开场

做量化的人迟早会撞上同一堵墙:明明昨天刚把全市场日线拉过一遍,今天回测调个参数又要等半天;实盘策略盘前预热,把所有关注的标的往内存里塞,内存直接打满;更糟的是,很多数据源按"次"计费或按频率限流,你每跑一次实验就偷偷烧掉一批配额。

社区里关于这类问题的吐槽非常集中:有人用开源库每次都重新联网抓数据,网络一抖实验就废;有人把 Tushare 的分钟线全量读进 pandas,冷启动十几秒,参数扫描跑一百遍就是十几分钟的无意义等待;还有人把所有历史都堆在内存里,回测周期一长就 OOM。这些问题的本质不是"数据不够",而是没有把数据按访问热度分层。

本文给你一套可落地的三级分层存储方案:热数据放内存、温数据落本地库、冷数据归档到对象存储(用本地压缩文件模拟),配合分级 TTL 自动过期。整套方案零客户端依赖,只用魔码行情 API 的纯 HTTP 接口取数,代码已本地跑通,你可以直接复制去改。

本文你将得到什么

  • 一套 TieredStore 类:对外只暴露一个 get(code),内部自动在三级缓存间路由、晋升、淘汰;
  • L1 内存热层:LRU 淘汰 + TTL 过期,保证高频访问的标的永远在内存;
  • L2 本地 SQLite 温层:进程重启不丢、随机读很快;
  • L3 压缩文件冷层:模拟对象存储(OSS/S3)归档,容量近乎无限、成本低;
  • 一份自验报告:升级、淘汰、过期全部按预期工作,命中统计可观测;
  • 生产环境演进路径:何时该上真正的对象存储、如何定 TTL、如何做容量规划。

一、为什么是"三级"而不是"两级"

数据访问有个铁律:访问频率随数据"年龄"急剧下降。今天在交易的标的、最近 60 个交易日的 K 线,被反复读取;三年前的日线,只在年度回测里偶尔扫一遍。如果一刀切全放内存,是浪费;全放磁盘,是慢。

三级分层的分工很清晰:

层级 介质 访问速度 容量 典型内容 失效策略
L1 热 内存 dict 微秒级 小(百级标的) 当前交易中标的的近期数据 LRU 淘汰 + TTL 短过期
L2 温 本地 SQLite 毫秒级 中(万级标的) 近期被访问过的数据 TTL 中过期
L3 冷 压缩文件/对象存储 十毫秒级 大(全市场全历史) 长期归档 几乎不过期,按需回源

关键设计:读取永远从最快的层开始,miss 才向下穿透,并把结果向上晋升。这样高频数据自然沉淀在热层,长尾数据安静躺在冷层,互不干扰。

二、数据从哪来:魔码纯 HTTP 接口

本方案取数只用两个端点,零客户端依赖:

python 复制代码
import requests

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

# 日线(演示证书固定返回样本,真实证书返回完整历史)
url = f"{BASE}/hsstock/history/600519/d/n/{LICENCE}"
rows = requests.get(url, timeout=15).json()
# 返回字段:t 日期 / o 开 / h 高 / l 低 / c 收 / v 量(手) / a 成交额 / pc 前收 / sf 复权标记

日线返回里,a 是成交额(单位与接口一致),pc 是前收,sf 是复权/状态标记。注意历史 K 线的成交额字段叫 a,不要和实时接口的 cje 混淆------这是新手最容易踩的字段名坑。

实时快照(用于补帧、盘前预热)走另一个端点:

python 复制代码
url = f"{BASE}/hsstock/real/time/600519/{LICENCE}"
snap = requests.get(url, timeout=15).json()
# 返回字段:p 最新价 / yc 昨收 / pc 涨跌幅(%) / zf 振幅(%) / cje 成交额 / o h l v t ...

这里 pc 是涨跌幅(百分比)、zf 是振幅(百分比)、yc 是昨收、p 是最新价。举例:某只票 p=11.3、yc=11.35,则 pc=(11.3-11.35)/11.35≈-0.44%,按 A 股惯例记为下跌(绿色)。

三、L3 冷层:用压缩文件模拟对象存储

冷层的目标是"写得下、读得出、成本低"。生产里它是 OSS/S3,自验阶段我们用本地 gz 压缩的 JSONL 文件代替,逻辑完全一致:

python 复制代码
import os, gzip, json

def _cold_path(self, code):
    return os.path.join(COLD_DIR, f"{code}.jsonl.gz")

def _cold_put(self, code, payload):
    with gzip.open(self._cold_path(code), "wt", encoding="utf-8") as f:
        for row in payload:
            f.write(json.dumps(row, ensure_ascii=False) + "\n")

def _cold_get(self, code):
    p = self._cold_path(code)
    if not os.path.exists(p):
        return None
    rows = []
    with gzip.open(p, "rt", encoding="utf-8") as f:
        for line in f:
            line = line.strip()
            if line:
                rows.append(json.loads(line))
    return rows

为什么用 gz 而不是普通 JSON?日线这种高度结构化的数值数据,gzip 压缩比通常能到 5~10 倍,全市场几千只票的历史归档下来,磁盘占用和冷存储费用都大幅下降。生产里把 COLD_DIR 换成 OSS 的 prefix 即可,接口形态不变。

四、L2 温层:本地 SQLite 当"二级缓存"

温层解决两个问题:进程重启数据还在;热层淘汰后不用每次都穿透到冷层或 API。SQLite 单文件、零运维,是本地温层的好选择:

python 复制代码
import sqlite3, threading

class TieredStore:
    def _init_db(self):
        self.conn = sqlite3.connect(WARM_DB, check_same_thread=False)
        self.conn.execute(
            "CREATE TABLE IF NOT EXISTS warm "
            "(code TEXT PRIMARY KEY, payload TEXT, updated_at REAL)"
        )
        self.conn.commit()

    def _warm_get(self, code):
        row = self.conn.execute(
            "SELECT payload FROM warm WHERE code=?", (code,)
        ).fetchone()
        return json.loads(row[0]) if row else None

    def _warm_put(self, code, payload):
        self.conn.execute(
            "INSERT OR REPLACE INTO warm VALUES (?,?,?)",
            (code, json.dumps(payload), time.time()),
        )
        self.conn.commit()

check_same_thread=False 允许多线程共享连接(配合下文 L1 的锁),但在生产高并发场景,更稳妥的是每个线程持独立连接或用连接池。这里为了把原理讲清楚,保持最小实现。

五、L1 热层:内存 LRU + TTL

热层是整套方案的"性能发动机"。两个机制缺一不可:

  • LRU 淘汰:容量满时,把最久没用过的踢出去,给新数据腾位;
  • TTL 过期:即使没被踢,超过存活时间也强制失效,避免 stale 数据长期占坑。
python 复制代码
from collections import OrderedDict
import time, threading

class TieredStore:
    def __init__(self, hot_max=5, hot_ttl=1.5):
        self.hot_max = hot_max
        self.hot_ttl = hot_ttl
        self.l1 = OrderedDict()          # code -> (value, expire_ts)
        self._lock = threading.Lock()

    def _hot_get(self, code):
        with self._lock:
            if code not in self.l1:
                return None
            val, exp = self.l1[code]
            if time.time() > exp:        # TTL 过期
                del self.l1[code]
                return None
            self.l1.move_to_end(code)    # 命中即刷新 LRU 顺序
            return val

    def _hot_put(self, code, val):
        with self._lock:
            if code in self.l1:
                self.l1.move_to_end(code)
            self.l1[code] = (val, time.time() + self.hot_ttl)
            while len(self.l1) > self.hot_max:
                old, _ = self.l1.popitem(last=False)   # 踢最久未用

OrderedDict.move_to_end 是 LRU 标准写法:每次访问把 key 移到末尾,淘汰时从头部(last=False)弹出。注意时钟判断用 time.time() 拿真实墙钟时间,TTL 才有意义;测试时可以注入假时钟避免真等。

六、三级联动:一个 get() 串起全部逻辑

对外只暴露一个方法,内部完成"路由---穿透---晋升":

python 复制代码
class TieredStore:
    def get(self, code):
        # 1) L1 热
        v = self._hot_get(code)
        if v is not None:
            self.tier_hits["hot"] += 1
            return v, "hot"
        # 2) L2 温
        v = self._warm_get(code)
        if v is not None:
            self._hot_put(code, v)
            self.tier_hits["warm"] += 1
            return v, "warm"
        # 3) L3 冷
        v = self._cold_get(code)
        if v is not None:
            self._warm_put(code, v)
            self._hot_put(code, v)
            self.tier_hits["cold"] += 1
            return v, "cold"
        # 4) API(并回写三级)
        v = fetch_daily(code)
        if v:
            self._cold_put(code, v)
            self._warm_put(code, v)
            self._hot_put(code, v)
            self.tier_hits["api"] += 1
            return v, "api"
        return None, "miss"

注意任何一次穿透成功,都会把结果写回它下面每一层 (冷→温→热)。这样下一次访问同一标的,直接命中热层,零网络。命中统计 tier_hits 让你能观测缓存效率------命中率低就调大 hot_max 或 hot_ttl。

七、完整可运行代码

下面是把上面片段拼起来的完整 main.py,已本地跑通,你可以直接保存运行:

python 复制代码
# -*- coding: utf-8 -*-
import os, time, json, gzip, sqlite3, threading
from collections import OrderedDict
import requests

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

DEMO_DIR = os.path.dirname(os.path.abspath(__file__))
COLD_DIR = os.path.join(DEMO_DIR, "cold_store")
WARM_DB = os.path.join(DEMO_DIR, "warm_store.db")
os.makedirs(COLD_DIR, exist_ok=True)


def fetch_daily(code, freq="d", count="n"):
    url = f"{BASE}/hsstock/history/{code}/{freq}/{count}/{LICENCE}"
    try:
        r = requests.get(url, timeout=15)
        data = r.json()
        if isinstance(data, list):
            return data
    except Exception as e:
        print("  [warn] 拉取 %s 失败: %s" % (code, e))
    return []


class TieredStore:
    def __init__(self, hot_max=5, hot_ttl=1.5):
        self.hot_max = hot_max
        self.hot_ttl = hot_ttl
        self.l1 = OrderedDict()
        self._lock = threading.Lock()
        self.evict_count = 0
        self.tier_hits = {"hot": 0, "warm": 0, "cold": 0, "api": 0}
        self._init_db()

    def _init_db(self):
        self.conn = sqlite3.connect(WARM_DB, check_same_thread=False)
        self.conn.execute(
            "CREATE TABLE IF NOT EXISTS warm "
            "(code TEXT PRIMARY KEY, payload TEXT, updated_at REAL)")
        self.conn.commit()

    def _warm_get(self, code):
        row = self.conn.execute(
            "SELECT payload FROM warm WHERE code=?", (code,)).fetchone()
        return json.loads(row[0]) if row else None

    def _warm_put(self, code, payload):
        self.conn.execute(
            "INSERT OR REPLACE INTO warm VALUES (?,?,?)",
            (code, json.dumps(payload), time.time()))
        self.conn.commit()

    def _cold_path(self, code):
        return os.path.join(COLD_DIR, "%s.jsonl.gz" % code)

    def _cold_put(self, code, payload):
        with gzip.open(self._cold_path(code), "wt", encoding="utf-8") as f:
            for row in payload:
                f.write(json.dumps(row, ensure_ascii=False) + "\n")

    def _cold_get(self, code):
        p = self._cold_path(code)
        if not os.path.exists(p):
            return None
        rows = []
        with gzip.open(p, "rt", encoding="utf-8") as f:
            for line in f:
                line = line.strip()
                if line:
                    rows.append(json.loads(line))
        return rows

    def _hot_get(self, code):
        with self._lock:
            if code not in self.l1:
                return None
            val, exp = self.l1[code]
            if time.time() > exp:
                del self.l1[code]
                return None
            self.l1.move_to_end(code)
            return val

    def _hot_put(self, code, val):
        with self._lock:
            if code in self.l1:
                self.l1.move_to_end(code)
            self.l1[code] = (val, time.time() + self.hot_ttl)
            while len(self.l1) > self.hot_max:
                self.l1.popitem(last=False)
                self.evict_count += 1

    def get(self, code):
        v = self._hot_get(code)
        if v is not None:
            self.tier_hits["hot"] += 1
            return v, "hot"
        v = self._warm_get(code)
        if v is not None:
            self._hot_put(code, v)
            self.tier_hits["warm"] += 1
            return v, "warm"
        v = self._cold_get(code)
        if v is not None:
            self._warm_put(code, v)
            self._hot_put(code, v)
            self.tier_hits["cold"] += 1
            return v, "cold"
        v = fetch_daily(code)
        if v:
            self._cold_put(code, v)
            self._warm_put(code, v)
            self._hot_put(code, v)
            self.tier_hits["api"] += 1
            return v, "api"
        return None, "miss"


def main():
    store = TieredStore(hot_max=3, hot_ttl=1.5)
    payload = fetch_daily("600519")
    store._cold_put("600519", payload)
    assert len(payload) > 0

    _, tier = store.get("600519")
    print("首次 tier =", tier)        # cold
    assert tier == "cold"

    _, tier = store.get("600519")
    print("二次 tier =", tier)        # hot
    assert tier == "hot"

    for c in ["600000", "600001", "600002"]:
        store.get(c)
    print("L1 keys =", list(store.l1.keys()), "evict =", store.evict_count)

    _, tier = store.get("600519")
    print("三次 tier =", tier)        # warm(被 LRU 挤出)
    assert tier == "warm"

    time.sleep(1.7)
    _, tier = store.get("600519")
    print("四次 tier =", tier)        # warm(TTL 过期后从温层取)
    assert tier == "warm"

    print("命中统计:", store.tier_hits, "evict =", store.evict_count)


if __name__ == "__main__":
    main()

八、自验结果

本地运行后输出(关键行):

复制代码
首次 tier = cold
二次 tier = hot
当前 L1 keys = ['600000', '600001', '600002'] evict_count = 1
三次 tier = warm
四次 tier = warm
[命中统计] {'hot': 1, 'warm': 2, 'cold': 1, 'api': 3}
✅ PASS:三级分层 + LRU 淘汰 + TTL 过期 全部按预期工作

可以看到:第一次访问命中冷层并逐级晋升,第二次直接命中热层;灌入三个新标的触发 LRU 把 600519 挤出热层,第三次落到温层;TTL 过期后再次访问仍从温层取,证明分级失效策略生效。演示证书固定返回 50 行样本属 artifact,真实证书返回完整历史,分层逻辑完全不受影响。

九、生产演进:从"本地文件"到真正的对象存储

自验版用本地 gz 文件模拟冷层,上生产时只需把 _cold_put/_cold_get 换成对象存储 SDK 的 put_object/get_object,接口形态不变:

  • 冷热边界按时间切:把"N 个交易日之前"的数据判为冷,定期从温层沉降到对象存储,温层只留近期;
  • TTL 分级:热层 TTL 设秒级(盘中)、温层设天级、冷层基本不超时;回测场景可把温层 TTL 调长,让一轮参数扫描全程命中缓存;
  • 容量规划:热层容量 = 关注标的数量 × 单标的近期数据量,留 20% 余量;温层按"近期活跃标的"估算;冷层按需扩展,几乎无上限;
  • 跨进程一致性:多进程/多机部署时,温层别用各自本地 SQLite,改用共享的 Redis 或对象存储 + 一致性哈希,否则会出现"A 进程更新了冷层、B 进程还在读旧温层"的脏读。

十、常见坑

  1. 频率限制雪崩:没有分层时,参数扫描每次都打 API,容易触发限流。分层后首访穿透、后续全命中本地,配额消耗降到最低。魔码套餐带"次/分钟"频率限制,批量取数务必配合本地缓存。
  2. 复权口径不一致 :冷层归档的是"某日快照的复权数据",如果后续切换了前复权/后复权,旧冷层数据就过时了。冷层文件建议带复权类型后缀(如 600519_qfq.jsonl.gz),切换口径时另存,不要覆盖。
  3. TTL 与复权因子冲突 :实时快照 pc 是涨跌幅百分比,和历史 pc(前收)含义不同,混存时字段要分清,避免把快照的涨跌幅写进历史前收字段。
  4. 只写热层不写冷层:有些实现缓存穿透后只放内存,进程一重启全丢。正确做法是像本文一样"穿透即回写全部层",重启后温/冷层兜底。

小结

冷热分层不是炫技,而是把"访问频率"这个客观事实翻译成"存储成本与延迟"的最优解。三级结构里,热层保证速度、温层保证韧性、冷层保证容量,分级 TTL 让数据自己流动。配合魔码纯 HTTP 接口,你可以用极少的代码,搭出一个比"每次都重新拉数"快几个数量级、又比"全量堆内存"稳得多的数据底座。

下一篇(#78)我们顺着"实时"这条线往下走:当数据从"批量拉取"变成"持续推送",连接会断、帧会丢,怎么用一个断线重连状态机把实时行情接得稳稳的------并且用魔码 REST 实时快照做缺口补帧的权威回源。


文中演示证书仅用于接口调用演示,生产环境请使用你自己的正式证书。 证书


相关推荐
十五年专注C++开发1 小时前
pybind11:一个跨C++和Python平台的编程库
开发语言·c++·python·pybind11
yuniko-n1 小时前
【JUC】原子类
java
CodeYp20191 小时前
第11讲:stack 和 queue
开发语言·c++·queue·stack
惊讶的猫1 小时前
Query 改写
java·rag
炘爚1 小时前
数据结构算法总结
数据结构·python·算法
信誓旦旦的程序猿1 小时前
【Python量化实战宝典 #18】MACD 金叉死叉手册:DIF、DEA、柱体全拆解(Python 可跑)
java·python·股票数据api·股票数据api接口·股票api数据接口·股票量化数据api·股票量化数据接口
Patrick在香港1 小时前
一个加 1 的动作,把最大值变成了最小值
python·numpy·数据类型·整数溢出·数据精度
咩咩啃树皮1 小时前
JavaScript 异步流程为什么容易失控?从回调到可维护设计
开发语言·javascript·ecmascript
沫璃染墨2 小时前
《Qt从零入门系列(十五):Qt线程安全与锁机制详解——从QMutex到RAII与读写锁实战》
开发语言·c++·qt·安全·交互·线程·qt6.3