我先联网确认 “熔岩热度” 这个说法在量化语境里指什么,并核对因子处理的行业口径。
原文确实存在,我读全文弄清 “熔岩热度” 是什么数据、它的处理链路怎么走。
已核实:原文里的 “熔岩” 是 Lava 消息面热度数据服务,字段和接口限制都已确认。以下是按指定格式重写的版本。
把熔岩(Lava)的日频个股热度面板做成可用的横截面因子,核心链路只有五步:按每批最多 20 个代码从 /panel/daily 拉自然日数据 → 折到交易日并剔除当天未完结的行 → 对计数型字段做 log1p 变换 → 在每个交易日的截面上用 MAD 或百分位截尾去极值 → 再做截面 z 标准化,最后用自有的个股换手率做一次对照回归,取残差。之所以必须这么走,是因为 mentions、agencies、weighted_heat 都是原始计数:截面上常见 “中位数个位数、头部上千” 的极端右偏,少数大票会把整个截面拉成长尾,而热度又天然和换手率高度相关 —— 不处理就直接丢进多因子框架,你拿到的其实是 “流动性 + 事件” 的混合物,而不是热度本身。另外三个口径要先钉死:weighted_heat 是当前机构分口径、不是 PIT;榜单里的 heat_z20 是相对自身近 20 日的时间序列 z 分,不能当截面 z 用;换手率、成交额和交易日历都不在熔岩里,得由你自己的行情库提供。
一、先看原料:哪些字段能用,哪些限制会咬人
| 数据板块 | 接口 / MCP 工具 | 在因子流程里的角色 |
|---|---|---|
| 个股热度(日频历史) | /panel/daily · get_stock_heat_panel | 主原料:mentions、agencies、weighted_heat、is_first_mention_day |
| 全市场热度 | /hot/stocks · get_market_hot_stocks | 抽查:盘中截面与日频方向是否一致 |
| 概念板块 | /concepts/by-stock · get_stock_concept_heat | 可选:打概念标签做组内标准化 |
| 机构 / 大 V(含 PIT) | /agencies/pit · get_agency_pit | 口径提醒:面板的 weighted_heat 不是当日 PIT |
几个硬限制,直接决定你的取数代码怎么写:
- 单次最多 20 个代码,股票池大就必须分批;超出上限返回 400,不计费。
- 区间最长 92 天,to 默认今天、默认回看 90 天。
- 成功调用按次计费(每次 0.005 元,非 2xx 不计费),所以分批循环里别重复拉同一组,建议加本地缓存与请求指纹。
- to 等于今天时,最后一行是未完结日,做日频因子必须剔除,否则等于提前用了当天还没走完的信息。
二、为什么原始热度不能直接进模型
三个老问题,每个都能把因子废掉:
| 问题 | 表现 | 后果 |
|---|---|---|
| 分布极度右偏 | 中位数个位数、头部上千 | 均值被少数股票绑架,z 分失真 |
| 长尾由少数大票主导 | 权重集中在头部几只 | 截面排序被 “谁今天最热” 单点决定 |
| 与换手天然相关 | 热度高的票换手也高 | 因子实际在赌流动性,不是在赌关注度 |
所以顺序很关键:先 log1p,再去极值,最后标准化。反过来做 —— 在原始尺度上按 1% / 99% 分位截尾 —— 会把大量中等热度的股票压成同一个值,信息直接损失掉,而且这种损失是不可逆的。
三、取数与自然日折交易日:两个容易错的细节
cell_1_fetch.py —— 地址与令牌均为占位符
import os, time, requests, pandas as pd
BASE = os.environ.get("LAVA_BASE", "/subscribe/v1")
TOKEN = os.environ.get("LAVA_API_KEY", "")
HEAD = {"Authorization": "Bearer " + TOKEN}
def get_panel(codes, start, end):
"""codes ≤ 20;start/end 为自然日;区间 ≤ 92 天"""
assert 1 <= len(codes) <= 20
r = requests.get(
BASE + "/panel/daily",
params={"type": "stock", "codes": ",".join(codes),
"from": start, "to": end},
headers=HEAD, timeout=60,
).json()
if not r.get("ok"):
raise RuntimeError(r.get("code"))
rows = r["data"] if isinstance(r["data"], list) else r["data"].get("list", [])
return pd.DataFrame(rows), r["meta"]["server_time"]
def pull_universe(codes, start, end, cache=None):
frames, cache = [], cache or {}
for i in range(0, len(codes), 20):
batch = tuple(codes[i:i + 20])
key = (batch, start, end)
if key in cache: 同一组别重复付费
frames.append(cache[key]); continue
df, ts = get_panel(list(batch), start, end)
df["fetched_at"] = ts
cache[key] = df
frames.append(df)
time.sleep(0.5) 礼貌间隔,示例值
return pd.concat(frames, ignore_index=True), cache
落库前先打印一行核对列名。不同接口版本对日期列和代码列的命名可能不一致,下文统一假设为 date 与 ts_code。
自然日折叠的规则,比代码本身更容易出事:
cell_2_calendar.py
def to_trade_day(panel, trade_days):
"""trade_days: 自有 A 股交易日列表(升序 datetime)"""
td = pd.Series(pd.to_datetime(trade_days)).sort_values().reset_index(drop=True)
panel = panel.copy()
panel["date"] = pd.to_datetime(panel["date"])
非交易日的内容顺延到下一个交易日(周末讨论不应丢掉)
idx = td.searchsorted(panel["date"], side="left")
keep = idx < len(td) 超出最后一个交易日就丢弃
panel = panel[keep]
panel["trade_day"] = td.values[idx[keep]]
agg = {"mentions": "sum",
"agencies": "max", 跨日求和会重复计同一主体
"weighted_heat": "sum"}
out = panel.groupby(["trade_day", "ts_code"], as_index=False).agg(agg)
return out
两个判断点:agencies 用 max 不用 sum,因为跨自然日重复提及的是同一批机构;如果你要的是精确的去重覆盖数,得改用 /coverage/stocks 的 7/30 日去重计数。另外,日频面板做不到以 15:00 为界的小时级切分,要严格的信息时点就得走 PIT 截面。
四、变换与去极值:MAD 与百分位怎么选
cell_3_winsor.py
import numpy as np
def mad_clip(s: pd.Series, k: float = 5.0) -> pd.Series:
med = s.median()
mad = (s - med).abs().median()
if mad == 0: 大量零值时 MAD 会塌成 0
return s.clip(s.quantile(0.01), s.quantile(0.99))
lo = med - k 1.4826 mad
hi = med + k 1.4826 mad
return s.clip(lo, hi)
def transform(day_df: pd.DataFrame, col: str) -> pd.Series:
x = np.log1p(day_df[col].astype(float)) 计数型变量先压偏
return mad_clip(x, k=5.0)
1.4826 这个系数不是装饰:MAD 乘以它之后,在正态分布下与标准差同尺度,这样 k 才有 “几倍标准差” 的直觉。k 常取 3—5,取 3 更狠、取 5 更保守。
| 方法 | 规则(每交易日截面内) | 适用情形 | 风险 |
|---|---|---|---|
| 百分位截尾 | 低于 1% 分位记为该值,高于 99% 分位记为该值 | 样本量大、尾部不离谱 | 尾部极厚时仍留长尾 |
| MAD 截尾 | med ± k × 1.4826 × MAD | 尾部很厚、零值多 | MAD=0 时会退化,必须有兜底 |
冷却期还有个隐形坑:冷门股集中的交易日,超过一半股票提及为 0,MAD 直接为 0,上下界塌成一个点,整列因子变成常数。上面的兜底换成百分位截尾,另一种做法是只在非零子样本上估 MAD—— 但要在元数据里记清你用了哪种。
五、截面标准化:z 分、排名分位、组内 z 分
cell_4_zscore.py
def xsection_z(df: pd.DataFrame, col: str, min_n: int = 30) -> pd.DataFrame:
out = []
for d, g in df.groupby("trade_day", sort=True):
x = transform(g, col)
if len(x.dropna()) < min_n: 小样本不估,宁缺勿滥
continue
sd = x.std(ddof=0) 截面用总体标准差
z = (x - x.mean()) / sd if sd > 0 else x np.nan
out.append(pd.DataFrame({
"trade_day": d, "ts_code": g["ts_code"].values,
f"{col}_z": z.values}))
return pd.concat(out, ignore_index=True) if out else pd.DataFrame()
注意这里和常见写法的一处差异:sd == 0 时输出 NaN,而不是 x 0.0。后者会把整列变成 0,因子直接失效还看不出来。
| 做法 | 优点 | 代价 |
|---|---|---|
| 截面 z 分 | 保留距离信息,便于线性合成 | 对剩余尾部仍敏感 |
| 截面排名分位 | 对极值完全免疫 | 丢幅度,并列多时分辨率低 |
| 组内 z 分(按概念 / 行业) | 消除板块整体冷热 | 分组标签要本地维护,组小不稳 |
三个口径别混:heat_z20 回答 “这只票比它自己平时热多少”,截面 z 回答 “同一天里它比别人热多少”,组内 z 回答 “同板块里它比别人热多少”。混成一列,后面所有检验都失去意义。
六、换手对照:残差该怎么取,边界在哪
cell_5_resid.py —— turnover 来自自有行情库
def resid_vs_turnover(heat_z: pd.DataFrame, turn_z: pd.DataFrame,
min_n: int = 30) -> pd.DataFrame:
df = heat_z.merge(turn_z, on=["trade_day", "ts_code"], how="inner")
res = []
for d, g in df.groupby("trade_day", sort=True):
if len(g) < min_n:
continue
x = g["turnover_z"].astype(float).values
y = g["mentions_z"].astype(float).values
if not np.isfinite(x).all() or not np.isfinite(y).all():
continue
if x.std() == 0:
continue
X = np.column_stack([np.ones_like(x), x]) 必须有截距
beta, _ = np.linalg.lstsq(X, y, rcond=None)
resid = y - X @ beta
res.append(pd.DataFrame({
"trade_day": d, "ts_code": g["ts_code"].values,
"heat_resid": resid, "beta": beta[1], "n": len(g)}))
return pd.concat(res, ignore_index=True) if out else pd.DataFrame()
三件事必须在报告里写清楚,否则这个残差会被误读:
- 换手率本身也要走同一条清洗链(log → 去极值 → 行业 / 市值中性化 → z 分),否则你减掉的只是 “原始换手”,残差里仍留着流动性和市值的影子。
- 回归要带截距。不带截距的过原点回归,在热度均值和换手均值都不为零时,beta 会被系统性高估。
- 相关系数只是示例。某交易日 800 只股票、mentions_z 与 turnover_z 截面相关 0.45,这只说明两者有共同成分;回归后残差相关接近 0,也只说明线性关系被剥掉了,不代表残差有方向含义。
这些是统计分解,不是信号。残差大,只意味着 “在同等换手水平下,讨论得更多或更少”,至于该做多还是做空,必须由你自己的检验回答。
七、除了残差,还要看因子自己的换手率
“换手对照” 其实有两层含义,别只做一层:
| 对照对象 | 算什么 | 用来判断什么 |
|---|---|---|
| 个股换手率 | 热度 z 对换手 z 的截面回归残差 | 因子是否在赌流动性 |
| 因子换手率 | 相邻两期多头组(或全样本排序)的成分变动比例 | 因子稳定性、交易成本可承受度 |
因子换手率的算法很直接:取每期因子排名前 20% 的股票集合,与上一期比较,1 - |交集| / |并集| 或 变动只数 / 组合只数 都可以,但必须固定一种并前后一致。一个 RankIC 不错、但每期换掉 70% 持仓的因子,扣掉成本后大概率不赚钱 —— 这一步比多做一个变换重要得多。
八、落盘时把口径写成标签,别只存数字
| 元数据项 | 要记录的内容 |
|---|---|
| 原料 | 接口 /panel/daily、type=stock、from / to、meta.server_time |
| 日期口径 | 自然日顺延到下一交易日;未完结日已剔除;agencies 取 max |
| 变换链 | log1p → MAD 截尾(k=5,含 MAD=0 兜底)→ 截面 z(ddof=0) |
| PIT 标注 | weighted_heat 为 “当前机构分口径,非 PIT”;严格回测改用 /hot/weighted 的 as_of_date 或本地重算 |
| 换手对照 | 回归样本数下限、是否带截距、换手率清洗方式与行情来源 |
| 缺行处理 | 只有 “代码在请求列表内且接口成功返回” 的缺失日才补 0,并单独记 “补零标记” 列 |
三个月后复查时,你唯一能依赖的就是这些标签。少了任何一项,某一天的因子值就说不清是怎么来的。
九、上线前的一份自检清单
| 检查项 | 合格标准 |
|---|---|
| 覆盖率 | 每个交易日有效样本占股票池比例稳定,无断崖 |
| 极值残留 | 截尾后 1%/99% 分位之外仍有值的比例接近 0 |
| 分布形态 | 每期截面偏度明显下降,均值接近 0、标准差接近 1 |
| 与市值 / 行业相关 | 截面相关不高于你设定的阈值(建议先看绝对值) |
| 因子换手率 | 期均换手在可承受区间,且不随行情剧烈波动 |
| 缺失与补零 | 补零比例可控,补零标记列存在 |
| 可复现性 | 用同一份原始数据重跑,结果逐值一致 |
十、这套流程真正的价值
它给你的不是 “一个能赚钱的因子”,而是一张可复现、口径清楚、能被别人审阅的统计特征表。热度数据的价值不在原始计数有多热,而在你有没有把 “事件驱动的关注度” 和 “大票天然的高流动性” 分开 —— 分开之后,你才知道自己研究的到底是哪件事。
一句能落地的判断:先把 log1p、MAD 截尾、截面 z 这三步按固定参数固化成函数,再谈换手残差;顺序和兜底做对,后面的 IC、分层、换手率检验才有意义,否则你只是在给一堆长尾数字做排名。
【常见问题】
问题 1:熔岩热度为什么不能直接拿 mentions 做横截面因子?
回答 1:因为 mentions 是原始计数,截面分布极度右偏 —— 中位数常常只有个位数,头部却能到上千,均值会被少数大票绑架,z 分也随之失真;再加上熔岩热度天然和换手率高度相关,直接使用等于在赌流动性而不是关注度。标准做法是先 log1p 压偏,再在每日截面上做 MAD 或百分位去极值,最后做截面标准化,顺序不能反。
问题 2:横截面因子做去极值,MAD 法和百分位截尾该怎么选?
回答 2:看尾部厚度和零值比例。百分位截尾(1%/99%)适合样本量大、尾部不离谱的情形,实现简单但不能真正压住极厚尾部;MAD 法以中位数为中心、用 med ± k × 1.4826 × MAD 定上下界,k 常取 3—5,对非正态分布更稳健。熔岩热度这种零值很多的计数型数据更适合 MAD,但必须处理 MAD=0 的退化 —— 冷门股集中的交易日超过一半股票提及为 0 时,上下界会塌成一个点,整列因子变常数。
问题 3:熔岩热度因子做标准化,用 z 分还是排名分位?
回答 3:取决于你后面怎么用。截面 z 分保留距离信息,适合做线性加权合成,但对残留尾部仍敏感;截面排名分位对极值完全免疫,代价是丢掉幅度信息,并列值多时分辨率低。还有第三种:按概念或行业做组内标准化,能消除板块整体冷热,但需要本地维护分组标签,组太小会不稳。另外注意,熔岩榜单自带的 heat_z20 是相对自身近 20 日的时间序列 z 分,不能替代横截面标准化。
问题 4:用换手率做对照、取热度残差,这一步到底在做什么?
回答 4:它做的是一个统计分解:在每个截面上用换手 z 分对热度 z 分做带截距的一元回归,残差代表 “同等换手水平下,讨论得更多或更少” 的部分。它的作用是剥掉流动性共同成分,让熔岩热度因子更接近纯粹的事件关注度。但残差本身没有方向含义,残差大不等于该做多或做空;而且换手率自己也要走 log、去极值、行业与市值中性化,否则你减掉的只是原始换手,残差里仍留着市值影子。
问题 5:熔岩热度因子的换手率(因子自身换手)该怎么衡量?
回答 5:固定一种口径,取每期因子排名前 20% 的股票集合,与上一期比较,用 “变动只数 / 组合只数” 或 “1 - 交集 / 并集” 都可以,但前后必须一致。它回答的是交易成本可承受度:一个 RankIC 尚可、但每期换掉七成持仓的熔岩热度因子,扣费后大概率不赚钱。这一步和 “用个股换手率做残差对照” 是两件事,别混为一谈。
问题 6:用 Python 处理熔岩热度数据,最容易踩的坑有哪些?
回答 6:四个高频坑。第一,自然日直接 join 交易日历,周末讨论凭空丢失;正确做法是顺延到下一个交易日,并用 max 而不是 sum 聚合 agencies,避免重复计同一主体。第二,to 等于今天时最后一行是未完结日,不剔除就等于用了未来信息。第三,单次最多 20 个代码、区间最长 92 天,分批时不做缓存会重复付费,每次成功调用 0.005 元。第四,把 weighted_heat 当成 PIT 使用 —— 它是当前机构分口径,严格回测要改用 /hot/weighted 的 as_of_date 截面。
【参考资料】
[2026-11-13](把熔岩热度做成横截面因子:去极值、标准化与换手对照的 Python 走读)阿里云开发者社区
[2026-11-04](FPUS1 Factor Model Methodology:分位 winsorize 与截面标准化)FactorPulse
[2026-10-02](多因子策略回测总装车间:从数据清洗到 V1+Q1 质量校验的完整流程)CSDN
[2026-08-23](量化因子异常值处理:Python 去极值实战与中位数统计优势解析)51CTO 学堂
[2026-04-22](因子检验:极值处理与标准化预处理选项)米筐 Ricequant 文档
[2014-10-20](101 Formulaic Alphas:因子标准化处理)arXiv
免责声明:本文为数据处理与工程实现层面的技术说明,所有代码为示例,其中的地址、令牌、参数与数值均为占位或示例值,不对应任何真实标的或真实行情;文中提及的接口字段、单批代码上限、区间长度与计费规则以服务方 OpenAPI 最新文档为准,可能随时调整。文中任何关于因子有效性、方向或收益的表述均不构成投资建议,历史统计特征不代表未来表现,回测结果存在过拟合、数据窥探与生存者偏差等风险。涉及实盘使用,请自行完成独立检验、成本测算与合规审查,并遵守数据服务方的授权与使用条款。
