做基金研究有个容易踩的坑:把业绩排行当成数据集用。你今天看到某只股票型基金近一年收益排同类第37名,明天榜单一刷新,这个"第37名"就永远找不回来了------排行接口给的是快照,不是时间序列。想研究榜单迁移、排名动量、基金经理的业绩持续性,就得自己把每天的快照攒起来,重新组织成可研究的时间序列。最近我用本地数据引擎 ig50 把这条管道搭了一遍,按"单日快照→时间序列→排名稳定性"三层往下拆。
第一层:单日快照落盘,按日期分片。
业绩排行接口 all/jjsjzx/yjph/{基金分类参数} 每天20:30更新一次,字段有基金代码(dm)、基金名称(mc)、单位净值、累计净值、近三个月(%)、近六个月(%)、近一年(%)、今年以来(%)、成立以来(%)。股票型基金几千只,一份快照就是一个完整的横截面。数据是JSON文本文件直接落盘,我的存储设计很简单:目录按日期分片,一天一个文件,只存原始快照,不做任何加工------加工逻辑以后一定会迭代,原始数据必须保持可重放。
python
import json
from pathlib import Path
# 业绩排行接口:all/jjsjzx/yjph/{基金分类参数},每天20:30更新
# 目录按日期分片:yjph/20260831.json、yjph/20260901.json ...
YJPH_DIR = Path("local_data/all/jjsjzx/yjph")
def load_snapshot(day: str) -> dict:
"""读取某日快照,返回 {基金代码: 记录}"""
with open(YJPH_DIR / f"{day}.json", encoding="utf-8") as f:
rows = json.load(f)
return {r["基金代码"]: r for r in rows}
snap = load_snapshot("20260901")
print(len(snap)) # 股票型基金几千只
top5 = sorted(snap.values(),
key=lambda r: -r["近一年(%)"])[:5]
for r in top5:
print(r["基金代码"], r["基金名称"], r["近一年(%)"])
第二层:把快照串成时间序列,重算同类百分位排名。
有了日期分片,合并逻辑就是一层循环:把同一基金代码在各分片里的近一年(%)抽出来,按日期串成序列。排名我不直接用接口给的名次,而是自己按百分位重算------你要的是"同类中的相对位置",自己算口径完全可控,以后改口径也能全量重算。
python
def build_return_series(days: list[str]) -> dict:
"""多日期分片合并:{基金代码: [(日期, 近一年收益), ...]}"""
series = {}
for day in days:
for dm, r in load_snapshot(day).items():
series.setdefault(dm, []).append(
(day, r["近一年(%)"]))
for dm in series:
series[dm].sort(key=lambda x: x[0])
return series
def peer_percentile(snap: dict, dm: str) -> float:
"""某基金当日近一年收益在同类中的百分位(0~100,越高越强)"""
vals = sorted(r["近一年(%)"] for r in snap.values())
x = snap[dm]["近一年(%)"]
lo = sum(1 for v in vals if v < x)
return round(lo / len(vals) * 100, 2)
几千只基金一天的全量百分位是毫秒级计算,三年历史回算下来也就几秒。这是本地文件的另一个隐性好处:口径改动可以推倒重算,不用心疼调用次数,也不用写限流重试。
第二层里还有一件必须掰清楚的事:口径差异。排行接口的排名本质是"同类涨幅倒序",比的是同伴;而超额收益接口 all/jjsjzx/cesy/{基金分类参数} 的字段是近一月/三月/六月/一年/两年/三年收益率及各自超额收益率、业绩比较基准,比的是基准。两个口径经常背离:一只基金近一年排同类前10%,超额收益却可能是负的------不是它强,是同伴都涨得凶,它只是没跑输太多。所以我把两个接口做成互相校验:排名高且超额高,才算真强;排名高但超额为负,标记为"贝塔行情",这个标签在下游做组合初筛时能过滤掉一大批幻觉。
python
# 超额收益接口:all/jjsjzx/cesy/{基金分类参数}
CESY_DIR = Path("local_data/all/jjsjzx/cesy")
def check_rank_vs_excess(day: str, dm: str) -> dict:
"""排行口径与超额口径的交叉校验"""
pctl = peer_percentile(load_snapshot(day), dm)
with open(CESY_DIR / f"{day}.json", encoding="utf-8") as f:
excess = {r["基金代码"]: r for r in json.load(f)}
ex = excess[dm]["近一年超额收益率(%)"]
tag = ("真强" if pctl >= 75 and ex > 0
else "贝塔行情" if pctl >= 75 else "平庸")
return {"day": day, "pctl": pctl, "excess_1y": ex,
"benchmark": excess[dm]["业绩比较基准"], "tag": tag}
第三层:排名稳定性。
单日排名没有信息量,稳定性才有。我的度量很朴素:同一基金在最近30个交易日里,近一年收益排在同类前25%的天数占比。占比高,说明它的高排名是"住"在前排,而不是偶尔冲一次榜。
python
def rank_stability(days: list[str], dm: str,
top: float = 25.0) -> float:
"""近30日中,日度百分位进入同类前top%的天数占比"""
hits, total = 0, 0
for day in days[-30:]:
pctl = peer_percentile(load_snapshot(day), dm)
total += 1
hits += pctl >= 100 - top
return round(hits / total, 3)
# 经验锚点:近一年排名前25%的基金,
# 次年仍留在前25%的通常不足一半。
# 所以稳定性衡量的是"当前状态的一致性",
# 不能反过来当成对未来的保证------那是排名动量陷阱。
预期管理必须说在前面:既然近一年前25%的基金次年仍在前25%的通常不足一半,排名稳定性度量的就是当前状态的一致性,不是收益预测。管道的职责是把口径做干净,判断留给人。
最后补一句基金池的构建。全量基金代码走 base/jjdm,如果研究范围是主动管理型,就用 base/etfjj 的ETF列表做差集,剩下的才是"同类"------同类排名的"同类"两个字是否严谨,就由这个差集的干净程度决定。
整条管道走完:每天20:30快照按日期分片落盘 → 多分片合并成时间序列 → 日度百分位排名重算 → 超额口径交叉校验 → 30日稳定性统计。全程读本地文件,几千只基金的历史回算分钟级完成,夜间任务定在20:45跑一遍,第二天一早排名序列和稳定性报表都是现成的。
接口说明:
all/jjsjzx/yjph/{基金分类参数}:业绩排行,每天20:30更新。字段:基金代码、基金名称、单位净值、累计净值、近三个月(%)、近六个月(%)、近一年(%)、今年以来(%)、成立以来(%)。all/jjsjzx/cesy/{基金分类参数}:超额收益。字段:近一月/三月/六月/一年/两年/三年收益率及各自超额收益率、业绩比较基准。base/jjdm:基金代码列表。base/etfjj:ETF基金列表,用于构建主动基金的同类样本。
资料参考:ig50